@graph8/sdk 0.373.0 → 0.374.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.
Files changed (279) hide show
  1. package/README.md +3 -3
  2. package/blocks/_foundation/README.md +47 -0
  3. package/blocks/_foundation/block.json +13 -0
  4. package/blocks/_foundation/components/auth-gate.tsx +363 -0
  5. package/blocks/_foundation/server/auth.ts +815 -0
  6. package/blocks/campaigns/README.md +1 -1
  7. package/blocks/campaigns/block.json +34 -1
  8. package/blocks/campaigns/components/campaign-links.tsx +180 -0
  9. package/blocks/company-intelligence/components/research-run.tsx +6 -2
  10. package/blocks/crm/README.md +19 -0
  11. package/blocks/crm/block.json +205 -6
  12. package/blocks/crm/components/bulk-update.tsx +119 -0
  13. package/blocks/crm/components/contact-documents-tab.tsx +144 -0
  14. package/blocks/crm/components/contact-insights-tab.tsx +118 -0
  15. package/blocks/crm/components/contact-lookup-link.tsx +47 -6
  16. package/blocks/crm/components/contact-row-actions.tsx +43 -0
  17. package/blocks/crm/components/contact-tag-chips.tsx +113 -0
  18. package/blocks/crm/components/contacts-screen.tsx +46 -5
  19. package/blocks/crm/components/owner-assign.tsx +132 -0
  20. package/blocks/crm/components/record-open-links.tsx +79 -0
  21. package/blocks/crm/components/tag-assign.tsx +112 -0
  22. package/blocks/crm/components/tag-manager.tsx +178 -0
  23. package/blocks/crm/components/unlock-info-button.tsx +82 -0
  24. package/blocks/crm/components/verify-email-button.tsx +93 -0
  25. package/blocks/crm/hooks/use-contact-tags.ts +29 -0
  26. package/blocks/crm/lib/record-actions.ts +102 -0
  27. package/blocks/deals/README.md +1 -0
  28. package/blocks/deals/block.json +17 -0
  29. package/blocks/deals/components/create-deal-button.tsx +109 -0
  30. package/blocks/deals/components/deal-detail.tsx +5 -3
  31. package/blocks/dialer/components/call-log-screen.tsx +6 -1
  32. package/blocks/documents/README.md +86 -0
  33. package/blocks/documents/block.json +378 -0
  34. package/blocks/documents/components/account-bar.tsx +48 -0
  35. package/blocks/documents/components/correspondence-form.tsx +95 -0
  36. package/blocks/documents/components/correspondence-import.tsx +106 -0
  37. package/blocks/documents/components/correspondence-panel.tsx +191 -0
  38. package/blocks/documents/components/documents-screen.tsx +76 -0
  39. package/blocks/documents/components/library-document-detail.tsx +175 -0
  40. package/blocks/documents/components/library-document-form.tsx +105 -0
  41. package/blocks/documents/components/library-folder-form.tsx +74 -0
  42. package/blocks/documents/components/library-panel.tsx +235 -0
  43. package/blocks/documents/components/library-upload.tsx +160 -0
  44. package/blocks/documents/components/question-row.tsx +130 -0
  45. package/blocks/documents/components/questionnaire-detail.tsx +197 -0
  46. package/blocks/documents/components/questionnaires-panel.tsx +173 -0
  47. package/blocks/documents/components/record-documents-tab.tsx +109 -0
  48. package/blocks/documents/lib/documents-types.ts +252 -0
  49. package/blocks/documents/lib/download.ts +64 -0
  50. package/blocks/documents/recipes/follow-a-questionnaire.ts +26 -0
  51. package/blocks/documents/recipes/log-account-correspondence.ts +27 -0
  52. package/blocks/documents/recipes/organize-the-library.ts +25 -0
  53. package/blocks/email-signatures/README.md +123 -0
  54. package/blocks/email-signatures/block.json +341 -0
  55. package/blocks/email-signatures/components/email-signatures-screen.tsx +166 -0
  56. package/blocks/email-signatures/components/signature-assign-template.tsx +119 -0
  57. package/blocks/email-signatures/components/signature-block-editor.tsx +87 -0
  58. package/blocks/email-signatures/components/signature-campaign-form.tsx +165 -0
  59. package/blocks/email-signatures/components/signature-campaigns.tsx +75 -0
  60. package/blocks/email-signatures/components/signature-identity-form.tsx +131 -0
  61. package/blocks/email-signatures/components/signature-mailboxes.tsx +195 -0
  62. package/blocks/email-signatures/components/signature-policy.tsx +130 -0
  63. package/blocks/email-signatures/components/signature-preview.tsx +119 -0
  64. package/blocks/email-signatures/components/signature-report.tsx +77 -0
  65. package/blocks/email-signatures/components/signature-template-details.tsx +112 -0
  66. package/blocks/email-signatures/components/signature-template-form.tsx +109 -0
  67. package/blocks/email-signatures/hooks/use-signatures-data.ts +93 -0
  68. package/blocks/email-signatures/lib/signatures-types.ts +392 -0
  69. package/blocks/email-signatures/recipes/assign-a-signature-to-a-new-hire.ts +32 -0
  70. package/blocks/email-signatures/recipes/create-a-template-and-make-it-default.ts +35 -0
  71. package/blocks/email-signatures/recipes/signature-engagement-report.ts +16 -0
  72. package/blocks/enrichment/README.md +22 -4
  73. package/blocks/enrichment/block.json +145 -5
  74. package/blocks/enrichment/components/auto-enrichment-configs.tsx +134 -0
  75. package/blocks/enrichment/components/auto-enrichment-settings.tsx +82 -0
  76. package/blocks/enrichment/components/bulk-enrich-panel.tsx +7 -7
  77. package/blocks/enrichment/components/enrichment-activity.tsx +93 -0
  78. package/blocks/enrichment/components/enrichment-job-detail.tsx +18 -0
  79. package/blocks/enrichment/components/enrichment-screen.tsx +34 -3
  80. package/blocks/enrichment/components/list-pipelines.tsx +97 -0
  81. package/blocks/enrichment/components/pipeline-detail.tsx +214 -0
  82. package/blocks/enrichment/components/pipeline-editor.tsx +144 -0
  83. package/blocks/enrichment/components/pipeline-templates.tsx +90 -0
  84. package/blocks/enrichment/lib/enrichment-types.ts +12 -0
  85. package/blocks/enrichment/lib/pipeline-types.ts +234 -0
  86. package/blocks/forms/README.md +2 -0
  87. package/blocks/forms/components/forms-screen.tsx +6 -3
  88. package/blocks/inbox/README.md +40 -4
  89. package/blocks/inbox/block.json +130 -8
  90. package/blocks/inbox/components/blocked-senders.tsx +103 -0
  91. package/blocks/inbox/components/compose-message.tsx +193 -0
  92. package/blocks/inbox/components/inbox-screen.tsx +54 -6
  93. package/blocks/inbox/components/inbox-search.tsx +106 -0
  94. package/blocks/inbox/components/tag-manager.tsx +136 -0
  95. package/blocks/inbox/components/thread-actions.tsx +133 -0
  96. package/blocks/inbox/components/thread-view.tsx +2 -0
  97. package/blocks/inbox/hooks/use-inbox-mailboxes.ts +31 -0
  98. package/blocks/inbox/lib/inbox-manage.ts +227 -0
  99. package/blocks/newsletter/components/newsletter-screen.tsx +3 -1
  100. package/blocks/pages/block.json +10 -1
  101. package/blocks/pages/hooks/use-landing-page-paths.ts +60 -0
  102. package/blocks/programmatic-pages/README.md +82 -0
  103. package/blocks/programmatic-pages/block.json +292 -0
  104. package/blocks/programmatic-pages/components/programmatic-campaign-form.tsx +136 -0
  105. package/blocks/programmatic-pages/components/programmatic-generate.tsx +112 -0
  106. package/blocks/programmatic-pages/components/programmatic-generated-pages.tsx +210 -0
  107. package/blocks/programmatic-pages/components/programmatic-overview.tsx +84 -0
  108. package/blocks/programmatic-pages/components/programmatic-pages-screen.tsx +221 -0
  109. package/blocks/programmatic-pages/components/programmatic-preview.tsx +66 -0
  110. package/blocks/programmatic-pages/components/programmatic-publish.tsx +102 -0
  111. package/blocks/programmatic-pages/components/programmatic-settings.tsx +96 -0
  112. package/blocks/programmatic-pages/components/programmatic-source-fields.tsx +75 -0
  113. package/blocks/programmatic-pages/components/programmatic-source.tsx +106 -0
  114. package/blocks/programmatic-pages/components/programmatic-variables.tsx +84 -0
  115. package/blocks/programmatic-pages/hooks/use-programmatic-workspace.ts +19 -0
  116. package/blocks/programmatic-pages/lib/programmatic-types.ts +330 -0
  117. package/blocks/programmatic-pages/recipes/create-a-programmatic-campaign.ts +23 -0
  118. package/blocks/programmatic-pages/recipes/render-and-follow.ts +21 -0
  119. package/blocks/programmatic-pages/recipes/review-then-publish.ts +17 -0
  120. package/blocks/quotes/README.md +25 -1
  121. package/blocks/quotes/block.json +123 -6
  122. package/blocks/quotes/components/manual-products.tsx +144 -0
  123. package/blocks/quotes/components/quote-batch-actions.tsx +86 -0
  124. package/blocks/quotes/components/quote-documents.tsx +94 -0
  125. package/blocks/quotes/components/quote-evidence.tsx +95 -0
  126. package/blocks/quotes/components/quote-list.tsx +23 -5
  127. package/blocks/quotes/components/quote-settings.tsx +184 -0
  128. package/blocks/quotes/components/quote-templates.tsx +189 -0
  129. package/blocks/quotes/components/quote-viewer.tsx +16 -3
  130. package/blocks/quotes/components/quotes-screen.tsx +15 -5
  131. package/blocks/quotes/lib/quote-admin.ts +264 -0
  132. package/blocks/sequencer/README.md +55 -0
  133. package/blocks/sequencer/block.json +172 -5
  134. package/blocks/sequencer/components/enrollment-withdraw.tsx +71 -0
  135. package/blocks/sequencer/components/sequence-builder.tsx +13 -3
  136. package/blocks/sequencer/components/sequence-campaign-link.tsx +78 -0
  137. package/blocks/sequencer/components/sequence-detail.tsx +6 -0
  138. package/blocks/sequencer/components/sequence-edit-form.tsx +111 -0
  139. package/blocks/sequencer/components/sequence-enrollments.tsx +2 -1
  140. package/blocks/sequencer/components/sequence-manage-actions.tsx +119 -0
  141. package/blocks/sequencer/components/sequence-senders-panel.tsx +84 -0
  142. package/blocks/sequencer/components/sequence-templates.tsx +160 -0
  143. package/blocks/sequencer/components/sequences-screen.tsx +36 -6
  144. package/blocks/sequencer/components/step-ai-assist.tsx +196 -0
  145. package/blocks/sequencer/components/step-editor.tsx +10 -1
  146. package/blocks/sequencer/components/step-templates.tsx +224 -0
  147. package/blocks/sequencer/lib/sequencer-manage.ts +235 -0
  148. package/blocks/sequencer/lib/sequencer-utils.ts +4 -0
  149. package/blocks/signals/README.md +39 -0
  150. package/blocks/signals/block.json +287 -0
  151. package/blocks/signals/components/auto-seed-panel.tsx +111 -0
  152. package/blocks/signals/components/listener-form.tsx +184 -0
  153. package/blocks/signals/components/matched-posts.tsx +195 -0
  154. package/blocks/signals/components/post-engagements.tsx +114 -0
  155. package/blocks/signals/components/record-social-tab.tsx +49 -0
  156. package/blocks/signals/components/social-listeners-screen.tsx +250 -0
  157. package/blocks/signals/components/social-settings.tsx +128 -0
  158. package/blocks/signals/components/source-lists.tsx +83 -0
  159. package/blocks/signals/components/top-engagers.tsx +68 -0
  160. package/blocks/signals/components/x-integration.tsx +129 -0
  161. package/blocks/signals/lib/signals-types.ts +228 -0
  162. package/blocks/signals/recipes/preview-and-create-a-listener.ts +16 -0
  163. package/blocks/signals/recipes/spend-against-caps.ts +14 -0
  164. package/blocks/signals/recipes/triage-matched-posts.ts +18 -0
  165. package/blocks/visitors/components/traffic-chart.tsx +3 -2
  166. package/blocks/web-analytics/README.md +102 -0
  167. package/blocks/web-analytics/block.json +292 -0
  168. package/blocks/web-analytics/components/acquisition-tab.tsx +154 -0
  169. package/blocks/web-analytics/components/analytics-filters.tsx +161 -0
  170. package/blocks/web-analytics/components/audience-tab.tsx +135 -0
  171. package/blocks/web-analytics/components/conversions-tab.tsx +128 -0
  172. package/blocks/web-analytics/components/engagement-tab.tsx +123 -0
  173. package/blocks/web-analytics/components/overview-tab.tsx +102 -0
  174. package/blocks/web-analytics/components/pages-tab.tsx +148 -0
  175. package/blocks/web-analytics/components/ranked-table.tsx +103 -0
  176. package/blocks/web-analytics/components/realtime-tab.tsx +103 -0
  177. package/blocks/web-analytics/components/report-card.tsx +71 -0
  178. package/blocks/web-analytics/components/web-analytics-screen.tsx +90 -0
  179. package/blocks/web-analytics/hooks/use-report.ts +48 -0
  180. package/blocks/web-analytics/lib/analytics-scope.ts +155 -0
  181. package/blocks/web-analytics/lib/page-path.ts +14 -0
  182. package/blocks/web-analytics/lib/web-analytics-types.ts +187 -0
  183. package/blocks/web-analytics/recipes/best-landing-pages.ts +26 -0
  184. package/blocks/web-analytics/recipes/form-conversion-check.ts +19 -0
  185. package/blocks/web-analytics/recipes/weekly-traffic-report.ts +37 -0
  186. package/blocks/workbench/README.md +54 -2
  187. package/blocks/workbench/block.json +234 -5
  188. package/blocks/workbench/components/workbench-auto-enrich.tsx +88 -0
  189. package/blocks/workbench/components/workbench-automation.tsx +36 -0
  190. package/blocks/workbench/components/workbench-connections.tsx +136 -0
  191. package/blocks/workbench/components/workbench-crm-import.tsx +91 -0
  192. package/blocks/workbench/components/workbench-datafile-import.tsx +154 -0
  193. package/blocks/workbench/components/workbench-grid.tsx +70 -7
  194. package/blocks/workbench/components/workbench-pipelines.tsx +173 -0
  195. package/blocks/workbench/components/workbench-row-actions.tsx +8 -1
  196. package/blocks/workbench/components/workbench-schedule.tsx +191 -0
  197. package/blocks/workbench/components/workbench-screen.tsx +50 -17
  198. package/blocks/workbench/components/workbench-send-to-sequence.tsx +116 -0
  199. package/blocks/workbench/components/workbench-table-settings.tsx +53 -0
  200. package/blocks/workbench/components/workbench-triggers.tsx +178 -0
  201. package/blocks/workbench/hooks/use-connected-crms.ts +29 -0
  202. package/blocks/workbench/lib/api-status.ts +8 -0
  203. package/blocks/workbench/lib/automation-types.ts +257 -0
  204. package/dist/cli.js +257 -51
  205. package/dist/index.browser.js +1 -1
  206. package/dist/index.browser.js.map +1 -1
  207. package/dist/index.browser.mjs +1 -1
  208. package/dist/index.browser.mjs.map +1 -1
  209. package/dist/index.js +1 -1
  210. package/dist/index.js.map +1 -1
  211. package/dist/index.mjs +1 -1
  212. package/dist/index.mjs.map +1 -1
  213. package/dist/react.browser.js +1 -1
  214. package/dist/react.browser.js.map +1 -1
  215. package/dist/react.browser.mjs +1 -1
  216. package/dist/react.browser.mjs.map +1 -1
  217. package/dist/react.js +1 -1
  218. package/dist/react.js.map +1 -1
  219. package/dist/react.mjs +1 -1
  220. package/dist/react.mjs.map +1 -1
  221. package/dist/types/blocks/auth.d.ts +45 -0
  222. package/dist/types/blocks/create.d.ts +5 -2
  223. package/dist/types/version.d.ts +2 -2
  224. package/docs/api/forms.md +1 -1
  225. package/package.json +1 -1
  226. package/stacks/6sense/pages/contacts.tsx.tmpl +2 -0
  227. package/stacks/6sense.json +20 -0
  228. package/stacks/apollo/pages/calls.tsx.tmpl +9 -9
  229. package/stacks/apollo/pages/campaigns.tsx.tmpl +27 -0
  230. package/stacks/apollo/pages/contacts.tsx.tmpl +6 -0
  231. package/stacks/apollo/pages/inbox-contact.tsx.tmpl +3 -3
  232. package/stacks/apollo/pages/lists.tsx.tmpl +14 -2
  233. package/stacks/apollo/pages/sequences.tsx.tmpl +8 -2
  234. package/stacks/apollo.json +33 -2
  235. package/stacks/clay/pages/contacts.tsx.tmpl +2 -0
  236. package/stacks/clay/pages/lists.tsx.tmpl +14 -2
  237. package/stacks/gong/pages/calls.tsx.tmpl +9 -9
  238. package/stacks/gong/pages/contacts.tsx.tmpl +2 -0
  239. package/stacks/hootsuite/pages/contacts.tsx.tmpl +2 -0
  240. package/stacks/hootsuite.json +10 -0
  241. package/stacks/hubspot/pages/campaigns.tsx.tmpl +30 -0
  242. package/stacks/hubspot/pages/companies.tsx.tmpl +2 -0
  243. package/stacks/hubspot/pages/contacts.tsx.tmpl +6 -0
  244. package/stacks/hubspot/pages/deals.tsx.tmpl +4 -0
  245. package/stacks/hubspot/pages/forms.tsx.tmpl +36 -0
  246. package/stacks/hubspot/pages/inbox.tsx.tmpl +2 -2
  247. package/stacks/hubspot/pages/sequences.tsx.tmpl +8 -2
  248. package/stacks/hubspot/pages/web-analytics.tsx.tmpl +14 -0
  249. package/stacks/hubspot.json +45 -2
  250. package/stacks/intercom/pages/inbox-contact.tsx.tmpl +3 -3
  251. package/stacks/intercom.json +10 -0
  252. package/stacks/mailchimp/pages/contacts.tsx.tmpl +2 -0
  253. package/stacks/mailchimp/pages/forms.tsx.tmpl +36 -0
  254. package/stacks/mailchimp.json +2 -1
  255. package/stacks/outreach/pages/calls.tsx.tmpl +9 -9
  256. package/stacks/outreach/pages/campaigns.tsx.tmpl +27 -0
  257. package/stacks/outreach/pages/contacts.tsx.tmpl +2 -0
  258. package/stacks/outreach/pages/inbox-contact.tsx.tmpl +3 -3
  259. package/stacks/outreach/pages/sequences.tsx.tmpl +8 -2
  260. package/stacks/outreach.json +13 -2
  261. package/stacks/pipedrive/pages/contacts.tsx.tmpl +2 -0
  262. package/stacks/pipedrive/pages/inbox.tsx.tmpl +2 -2
  263. package/stacks/pipedrive.json +10 -0
  264. package/stacks/salesforce/pages/companies.tsx.tmpl +2 -0
  265. package/stacks/salesforce/pages/contacts.tsx.tmpl +6 -0
  266. package/stacks/salesforce/pages/deals.tsx.tmpl +4 -0
  267. package/stacks/salesforce.json +10 -0
  268. package/stacks/zoominfo/pages/contacts.tsx.tmpl +4 -0
  269. package/stacks/zoominfo/pages/lists.tsx.tmpl +14 -2
  270. package/stacks/zoominfo.json +20 -0
  271. package/styles/styles.css +1 -1
  272. package/templates/auth/auth-route.ts.tmpl +16 -0
  273. package/templates/auth/g8.auth.anonymous.ts.tmpl +15 -0
  274. package/templates/auth/g8.auth.basic.ts.tmpl +17 -0
  275. package/templates/auth/g8.auth.jwt.ts.tmpl +23 -0
  276. package/templates/auth/g8.auth.password.ts.tmpl +18 -0
  277. package/templates/auth/g8.auth.session.ts.tmpl +23 -0
  278. package/templates/auth/middleware.ts.tmpl +14 -0
  279. package/templates/next-app/README.md.tmpl +5 -0
@@ -0,0 +1,131 @@
1
+ "use client";
2
+
3
+ /**
4
+ * SignatureIdentityForm: give one mailbox a managed signature by binding it to an employee (and, optionally, a template).
5
+ *
6
+ * SDK operations, in order:
7
+ * 1. previewSignatureSetup (POST /signatures/setup-preview) write* "Check setup": a dry run against the mail provider. Confirms the
8
+ * employee account and the send-as address resolve and shows the signature installed there today. Changes nothing.
9
+ * 2. assignSignatureIdentity (POST /signatures/assignments) write a TAKEOVER: the mailbox's current signature is captured for
10
+ * restore and replaced, and the first install is queued. ConfirmSpendDialog (external) first, naming the mailbox.
11
+ * *A POST, so the contract tiers it as a write.
12
+ * The API requires the employee account to be the employee's own directory email and the send-as address to be the mailbox
13
+ * address, so both are shown read-only and derived from the two choices: nothing is typed or guessed. The mailbox's current
14
+ * revision (0 when it has no assignment yet) is sent so a stale screen cannot overwrite a newer change (409). When the mailbox
15
+ * is already assigned, its template, workspace integration, employee overrides and native-install request are carried over
16
+ * unchanged unless the person edits them here. A provider refusal (422) shows its state and reason.
17
+ */
18
+
19
+ import { useState } from "react";
20
+ import { Button, Label } from "@graph8/sdk/ui";
21
+ import { ApiError } from "@/g8/components/api-error";
22
+ import { ConfirmSpendDialog } from "@/g8/components/confirm-spend-dialog";
23
+ import { KeyValueList } from "@/g8/components/screen/key-value-list";
24
+ import { g8Post } from "@/g8/lib/g8-client";
25
+ import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
26
+ import { useSignatureDeployments, useSignatureMembers, useSignatureTemplates } from "../hooks/use-signatures-data";
27
+ import { isConflict, memberLabel, templateName } from "../lib/signatures-types";
28
+ import type { Assignment, SignatureMailbox } from "../lib/signatures-types";
29
+
30
+ export interface SignatureIdentityFormProps {
31
+ mailbox: SignatureMailbox;
32
+ onDone: () => void;
33
+ onCancel?: () => void;
34
+ }
35
+
36
+ interface SetupCheck { html?: string; plain?: string; identity_verified?: boolean; write_verified?: boolean; message?: string }
37
+
38
+ const SELECT = "border-input bg-background h-9 w-full rounded-md border px-2 text-sm";
39
+
40
+ export function SignatureIdentityForm({ mailbox, onDone, onCancel }: SignatureIdentityFormProps) {
41
+ const members = useSignatureMembers();
42
+ const templates = useSignatureTemplates();
43
+ const deployments = useSignatureDeployments();
44
+ const current = mailbox.deployment?.assignment;
45
+ const [memberId, setMemberId] = useState(current?.member_id ?? "");
46
+ const [templateId, setTemplateId] = useState(current?.template_id ?? "");
47
+ const [native, setNative] = useState(Boolean(current?.native_requested));
48
+ const [confirm, setConfirm] = useState(false);
49
+ const member = members.data?.find((m) => m.id === memberId);
50
+ const usable = Boolean(member?.email);
51
+
52
+ const assignment = (): Assignment => ({
53
+ member_id: memberId,
54
+ ...(templateId ? { template_id: templateId } : {}),
55
+ account_subject: member?.email ?? "",
56
+ send_as_email: mailbox.email,
57
+ ...(current?.integration_id ? { integration_id: current.integration_id } : {}),
58
+ overrides: current?.overrides ?? {},
59
+ native_requested: native,
60
+ });
61
+
62
+ const check = useG8Mutation(async (): Promise<SetupCheck> => (await g8Post<SetupCheck>("/signatures/setup-preview", { mailbox_id: mailbox.id, data: assignment() })).data);
63
+ const assign = useG8Mutation(async (): Promise<unknown> => (
64
+ await g8Post("/signatures/assignments", {
65
+ mailbox_ids: [mailbox.id],
66
+ expected_revisions: { [mailbox.id]: mailbox.deployment?.revision ?? 0 },
67
+ confirm_takeover: true,
68
+ data: assignment(),
69
+ })
70
+ ).data, {
71
+ onSuccess: () => { setConfirm(false); onDone(); },
72
+ onError: () => setConfirm(false),
73
+ invalidates: [["signatures", "deployments"]],
74
+ });
75
+
76
+ const err = assign.error;
77
+ return (
78
+ <form className="grid gap-3" onSubmit={(e) => { e.preventDefault(); if (usable) setConfirm(true); }}>
79
+ <p className="text-muted-foreground text-sm">Mailbox: <span className="text-foreground font-medium">{mailbox.email}</span></p>
80
+ <div>
81
+ <Label htmlFor="g8-sig-employee" className="mb-1">Employee</Label>
82
+ <select id="g8-sig-employee" className={SELECT} value={memberId} onChange={(e) => { setMemberId(e.target.value); check.reset(); }}>
83
+ <option value="">Choose an employee</option>
84
+ {(members.data ?? []).map((m) => <option key={m.id} value={m.id}>{memberLabel(m)}{m.department ? ` (${m.department})` : ""}</option>)}
85
+ </select>
86
+ {memberId !== "" && !usable ? <p role="alert" className="text-error mt-1 text-xs">This employee has no directory email, so a signature cannot be assigned to them.</p> : null}
87
+ </div>
88
+ <div>
89
+ <Label htmlFor="g8-sig-template" className="mb-1">Template</Label>
90
+ <select id="g8-sig-template" className={SELECT} value={templateId} onChange={(e) => setTemplateId(e.target.value)}>
91
+ <option value="">The department or organisation default</option>
92
+ {(templates.data ?? []).map((t) => <option key={t.id} value={t.id}>{templateName(t)}</option>)}
93
+ </select>
94
+ </div>
95
+ <KeyValueList columns={2} items={[
96
+ { label: "Employee account", value: member?.email ?? "" },
97
+ { label: "Send-as address", value: mailbox.email },
98
+ ]} />
99
+ <label className="flex items-center gap-2 text-sm"><input type="checkbox" checked={native} onChange={(e) => setNative(e.target.checked)} />Also install it inside the mail account when the policy allows native installation</label>
100
+ {check.data ? (
101
+ <div role="status" className="grid gap-1 rounded-md border p-3 text-sm">
102
+ <p>{check.data.message ?? "Checked."}</p>
103
+ {check.data.plain ? <p className="text-muted-foreground text-xs">Signature there today: {check.data.plain}</p> : null}
104
+ </div>
105
+ ) : null}
106
+ {check.error ? <ApiError error={check.error} title="The mail account could not be checked" /> : null}
107
+ {err && isConflict(err) ? (
108
+ <div className="grid gap-2">
109
+ <ApiError error={err} title="This mailbox changed since you opened it" />
110
+ <div><Button type="button" size="sm" variant="outline" onClick={() => { void deployments.refetch(); onCancel?.(); }}>Reload and start again</Button></div>
111
+ </div>
112
+ ) : err ? <ApiError error={err} title="Could not assign the signature" /> : null}
113
+ <div className="flex flex-wrap gap-2">
114
+ <Button type="button" variant="outline" disabled={!usable} loading={check.loading} onClick={() => void check.mutate(undefined)}>Check setup</Button>
115
+ <Button type="submit" disabled={!usable} loading={assign.loading}>{mailbox.deployment ? "Update assignment" : "Assign signature"}</Button>
116
+ {onCancel ? <Button type="button" variant="ghost" onClick={onCancel}>Cancel</Button> : null}
117
+ </div>
118
+ <ConfirmSpendDialog
119
+ open={confirm}
120
+ tier="external"
121
+ title={`Take over the signature of ${mailbox.email}?`}
122
+ affected={1}
123
+ description={<p>The signature this mailbox sends today is saved so it can be restored, then replaced by {templateId ? templateName(templates.data?.find((t) => t.id === templateId)) : "the default template"} for {memberLabel(member)}. New emails from this mailbox carry it.</p>}
124
+ confirmLabel="Take over signature"
125
+ confirming={assign.loading}
126
+ onCancel={() => setConfirm(false)}
127
+ onConfirm={() => void assign.mutate(undefined)}
128
+ />
129
+ </form>
130
+ );
131
+ }
@@ -0,0 +1,195 @@
1
+ "use client";
2
+
3
+ /**
4
+ * SignatureMailboxes: every mailbox with the signature it sends, its status, and the actions on it.
5
+ *
6
+ * SDK operations (the lists come from the shared hooks: listMailboxes + listSignatureDeployments, listSignatureTemplates,
7
+ * listSignatureMembers, getSignatureSettings):
8
+ * 1. pushSignature (POST /signatures/mailboxes/{mailbox_id}/push) write queue a re-compile and re-install of one mailbox
9
+ * 2. pushSignatures (POST /signatures/mailboxes/push) write the same for the selected mailboxes; all or none (422)
10
+ * 3. restoreSignature (POST /signatures/mailboxes/{mailbox_id}/restore) write put back the signature the mailbox had before takeover
11
+ * 4. changeSignatureSource (POST /signatures/mailboxes/{mailbox_id}/source) write what the mailbox sends: org default, a template, or its own
12
+ * Every one of them changes the signature a mailbox SENDS, so each goes through one ConfirmSpendDialog (external) that names
13
+ * the real number of mailboxes. Per-mailbox writes send the revision that was read (409 on a stale screen, then a refresh).
14
+ * Assigning an employee to a mailbox (the first step) opens SignatureIdentityForm in a sheet. Selection only holds mailboxes that
15
+ * still exist and are managed, and is cleared after a bulk push. Mailboxes with no signature yet are listed too; a key that
16
+ * cannot read mailboxes still sees the ones that have one, with a note. A deployment waiting for the mail admin's signature
17
+ * permission (needs_admin_grant) shows a link-out to the platform app, whose name and address come from useBrand.
18
+ */
19
+
20
+ import { useState } from "react";
21
+ import { RefreshCw, RotateCcw, UserPlus } from "lucide-react";
22
+ import { Button, Skeleton, Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@graph8/sdk/ui";
23
+ import { ApiError } from "@/g8/components/api-error";
24
+ import { ConfirmSpendDialog } from "@/g8/components/confirm-spend-dialog";
25
+ import { EmptyState } from "@/g8/components/empty-state";
26
+ import { FirstNotice } from "@/g8/components/paging-notices";
27
+ import { FormSheet } from "@/g8/components/screen/form-sheet";
28
+ import { StatusPill, humanizeStatus } from "@/g8/components/screen/status-pill";
29
+ import { Toolbar } from "@/g8/components/screen/toolbar";
30
+ import { g8Post } from "@/g8/lib/g8-client";
31
+ import { brandUrl, useBrand } from "@/g8/hooks/use-brand";
32
+ import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
33
+ import { plural } from "@/g8/lib/plural";
34
+ import { MAILBOX_LIMIT, useSignatureMailboxes, useSignatureMembers, useSignaturePolicy, useSignatureTemplates } from "../hooks/use-signatures-data";
35
+ import { SOURCE_CHOICES, formatWhen, isConflict, isManaged, memberLabel, statusTone, templateName } from "../lib/signatures-types";
36
+ import type { SignatureMailbox, SourceChoice } from "../lib/signatures-types";
37
+ import { SignatureIdentityForm } from "./signature-identity-form";
38
+
39
+ type Pending =
40
+ | { kind: "push"; mailbox: SignatureMailbox }
41
+ | { kind: "restore"; mailbox: SignatureMailbox }
42
+ | { kind: "bulk"; mailboxes: SignatureMailbox[] }
43
+ | { kind: "source"; mailbox: SignatureMailbox; source: SourceChoice; templateId: string };
44
+
45
+ interface Outcome { status?: string; message?: string | null }
46
+
47
+ const SELECT = "border-input bg-background h-9 rounded-md border px-2 text-sm";
48
+ const FILTERS = [{ value: "", label: "All mailboxes" }, { value: "attention", label: "Needs attention" }, { value: "managed", label: "Managed" }, { value: "unmanaged", label: "Not managed" }];
49
+
50
+ export function SignatureMailboxes() {
51
+ const brand = useBrand();
52
+ const box = useSignatureMailboxes();
53
+ const templates = useSignatureTemplates();
54
+ const members = useSignatureMembers();
55
+ const policy = useSignaturePolicy();
56
+ const [search, setSearch] = useState("");
57
+ const [filter, setFilter] = useState("");
58
+ const [picked, setPicked] = useState<number[]>([]);
59
+ const [assigning, setAssigning] = useState<SignatureMailbox | null>(null);
60
+ const [sourcing, setSourcing] = useState<SignatureMailbox | null>(null);
61
+ const [srcChoice, setSrcChoice] = useState<SourceChoice>("org");
62
+ const [srcTemplate, setSrcTemplate] = useState("");
63
+ const [pending, setPending] = useState<Pending | null>(null);
64
+ const [outcome, setOutcome] = useState<string | null>(null);
65
+
66
+ const run = useG8Mutation(async (p: Pending): Promise<Outcome> => {
67
+ if (p.kind === "bulk") return (await g8Post<Outcome>("/signatures/mailboxes/push", { mailbox_ids: p.mailboxes.map((m) => m.id) })).data;
68
+ const base = `/signatures/mailboxes/${p.mailbox.id}`;
69
+ const version = p.mailbox.deployment?.revision ?? 0;
70
+ if (p.kind === "push") return (await g8Post<Outcome>(`${base}/push`, { version })).data;
71
+ if (p.kind === "restore") return (await g8Post<Outcome>(`${base}/restore`, { version })).data;
72
+ return (await g8Post<Outcome>(`${base}/source`, { version, source: p.source, ...(p.source === "template" ? { template_id: p.templateId } : {}) })).data;
73
+ }, {
74
+ onSuccess: (r, p) => {
75
+ setPending(null);
76
+ setSourcing(null);
77
+ setPicked([]);
78
+ setOutcome(r.message ? `${r.status ?? "Done"}: ${r.message}` : `Queued: ${p.kind === "bulk" ? plural(p.mailboxes.length, "mailbox") : p.mailbox.email}.`);
79
+ },
80
+ onError: (e) => { setPending(null); if (isConflict(e)) box.refetch(); },
81
+ invalidates: [["signatures", "deployments"]],
82
+ });
83
+
84
+ const q = search.trim().toLowerCase();
85
+ const memberName = (id: string | undefined): string => (id ? memberLabel(members.data?.find((m) => m.id === id)) : "");
86
+ const shown = box.mailboxes.filter((m) => {
87
+ const d = m.deployment;
88
+ const okFilter = filter === "" || (filter === "attention" && Boolean(d?.alert)) || (filter === "managed" && isManaged(d)) || (filter === "unmanaged" && !isManaged(d));
89
+ return okFilter && (!q || `${m.email} ${memberName(d?.assignment?.member_id)}`.toLowerCase().includes(q));
90
+ });
91
+ const selectable = shown.filter((m) => isManaged(m.deployment));
92
+ const selected = box.mailboxes.filter((m) => picked.includes(m.id) && isManaged(m.deployment));
93
+ const where = (m: SignatureMailbox): string => {
94
+ const a = m.deployment?.assignment;
95
+ if (!isManaged(m.deployment)) return "Its own signature";
96
+ const named = (id: string | null | undefined): string => templateName(templates.data?.find((t) => t.id === id));
97
+ if (a?.template_id) return templates.data ? named(a.template_id) : "";
98
+ const dept = members.data?.find((x) => x.id === a?.member_id)?.department;
99
+ const deptTemplate = dept ? policy.data?.data.department_templates?.[dept] : undefined;
100
+ if (deptTemplate) return `${named(deptTemplate)} (department default)`;
101
+ return policy.data?.data.default_template_id ? `${named(policy.data.data.default_template_id)} (organisation default)` : "No default template is set";
102
+ };
103
+
104
+ const confirmText = (p: Pending): { title: string; body: string; count: number; label: string } => {
105
+ if (p.kind === "bulk") return { title: `Push the signature to ${plural(p.mailboxes.length, "mailbox")}?`, body: "Each mailbox is recompiled and its signature installed again.", count: p.mailboxes.length, label: "Push signatures" };
106
+ if (p.kind === "push") return { title: `Push the signature to ${p.mailbox.email}?`, body: "The mailbox is recompiled and its signature installed again.", count: 1, label: "Push signature" };
107
+ if (p.kind === "restore") return { title: `Restore the old signature of ${p.mailbox.email}?`, body: "The signature this mailbox had before takeover is put back and it is no longer managed. If it was edited elsewhere since, nothing is overwritten. Refused while the policy is enforced.", count: 1, label: "Restore signature" };
108
+ const what = p.source === "template" ? templateName(templates.data?.find((t) => t.id === p.templateId)) : p.source === "org" ? "the organisation or department default" : "its own signature";
109
+ return { title: `Make ${p.mailbox.email} send ${what}?`, body: "New emails from this mailbox carry the change.", count: 1, label: "Change signature" };
110
+ };
111
+ const text = pending ? confirmText(pending) : null;
112
+
113
+ const body = box.error ? <ApiError error={box.error} title="Could not load mailboxes" onRetry={box.refetch} /> : box.loading ? (
114
+ <div aria-busy="true" className="grid gap-2"><Skeleton className="h-10 w-full" /><Skeleton className="h-10 w-full" /><Skeleton className="h-10 w-full" /></div>
115
+ ) : shown.length === 0 ? (
116
+ <EmptyState title={box.mailboxes.length === 0 ? "No mailboxes yet" : "No mailboxes match"} description={box.mailboxes.length === 0 ? "Connect or buy a mailbox first, then give it a managed signature here." : "Try another search or filter."} />
117
+ ) : (
118
+ <>
119
+ <p role="status" className="text-muted-foreground mb-2 text-xs">{shown.length === box.mailboxes.length ? `All ${plural(box.mailboxes.length, "mailbox")}` : `${shown.length.toLocaleString()} of ${plural(box.mailboxes.length, "mailbox")}`}</p>
120
+ <Table>
121
+ <TableHeader>
122
+ <TableRow>
123
+ <TableHead className="w-8"><input type="checkbox" aria-label="Select all shown mailboxes" checked={selectable.length > 0 && selectable.every((m) => picked.includes(m.id))} onChange={(e) => setPicked(e.target.checked ? selectable.map((m) => m.id) : [])} /></TableHead>
124
+ <TableHead>Mailbox</TableHead><TableHead>Employee</TableHead><TableHead>Signature from</TableHead><TableHead>Status</TableHead><TableHead className="text-right">Actions</TableHead>
125
+ </TableRow>
126
+ </TableHeader>
127
+ <TableBody>
128
+ {shown.map((m) => {
129
+ const d = m.deployment;
130
+ const managed = isManaged(d);
131
+ return (
132
+ <TableRow key={m.id}>
133
+ <TableCell><input type="checkbox" aria-label={`Select ${m.email}`} disabled={!managed} checked={picked.includes(m.id)} onChange={(e) => setPicked(e.target.checked ? [...picked, m.id] : picked.filter((id) => id !== m.id))} /></TableCell>
134
+ <TableCell><span className="font-medium">{m.email}</span>{m.display_name ? <span className="text-muted-foreground block text-xs">{m.display_name}</span> : null}</TableCell>
135
+ <TableCell>{memberName(d?.assignment?.member_id) || <span className="text-muted-foreground">None</span>}</TableCell>
136
+ <TableCell>{where(m)}</TableCell>
137
+ <TableCell>
138
+ {d ? <StatusPill tone={statusTone(d)} title={d.error ?? undefined}>{humanizeStatus(d.status)}</StatusPill> : <StatusPill tone="neutral">Not managed</StatusPill>}
139
+ {d?.updated_at ? <span className="text-muted-foreground block text-xs">{formatWhen(d.updated_at)}</span> : null}
140
+ </TableCell>
141
+ <TableCell className="text-right">
142
+ <div className="flex flex-wrap justify-end gap-1">
143
+ <Button size="sm" variant="outline" aria-label={`${d ? "Change employee for" : "Assign a signature to"} ${m.email}`} onClick={() => setAssigning(m)}><UserPlus className="size-4" />{d ? "Employee" : "Assign"}</Button>
144
+ {managed ? <Button size="sm" variant="outline" aria-label={`Push signature to ${m.email}`} onClick={() => setPending({ kind: "push", mailbox: m })}><RefreshCw className="size-4" />Push</Button> : null}
145
+ {managed ? <Button size="sm" variant="ghost" aria-label={`Change source for ${m.email}`} onClick={() => { setSourcing(m); setSrcChoice("org"); setSrcTemplate(""); }}>Source</Button> : null}
146
+ {managed ? <Button size="sm" variant="ghost" aria-label={`Restore the old signature of ${m.email}`} onClick={() => setPending({ kind: "restore", mailbox: m })}><RotateCcw className="size-4" />Restore</Button> : null}
147
+ </div>
148
+ </TableCell>
149
+ </TableRow>
150
+ );
151
+ })}
152
+ </TableBody>
153
+ </Table>
154
+ </>
155
+ );
156
+
157
+ return (
158
+ <div className="grid gap-3">
159
+ <Toolbar
160
+ search={{ value: search, onChange: setSearch, placeholder: "Search mailboxes" }}
161
+ filters={<select aria-label="Filter mailboxes" className={SELECT} value={filter} onChange={(e) => setFilter(e.target.value)}>{FILTERS.map((f) => <option key={f.value} value={f.value}>{f.label}</option>)}</select>}
162
+ actions={<Button size="sm" disabled={selected.length === 0} onClick={() => setPending({ kind: "bulk", mailboxes: selected })}>Push to {plural(selected.length, "selected mailbox", "selected mailboxes")}</Button>}
163
+ />
164
+ {box.mailboxesError ? <ApiError compact error={box.mailboxesError} title="Mailboxes without a signature are not listed" onRetry={box.refetch} /> : null}
165
+ {box.mailboxes.some((m) => m.deployment?.status === "needs_admin_grant") ? (
166
+ <p role="note" className="rounded-md border p-3 text-sm">
167
+ Some mailboxes cannot be updated until a mail admin grants signature access. Do it in the <a className="underline" href={brandUrl(brand, "/studio/settings?tab=integrations")} target="_blank" rel="noreferrer">{brand.linkLabel} integrations settings</a>, then push them again.
168
+ </p>
169
+ ) : null}
170
+ {outcome ? <p role="status" className="text-sm">{outcome}</p> : null}
171
+ {run.error ? (isConflict(run.error) ? <ApiError error={run.error} title="This mailbox changed since the page loaded. It was refreshed: try again" /> : <ApiError error={run.error} title="Could not change the signature" />) : null}
172
+ {body}
173
+ <FirstNotice shown={box.mailboxes.length} limit={MAILBOX_LIMIT} noun="mailboxes" force={box.truncated} />
174
+ <FormSheet open={assigning !== null} onOpenChange={(o) => !o && setAssigning(null)} title={assigning?.deployment ? "Change the employee" : "Assign a signature"} description="Bind an employee to this mailbox. Their details fill the template.">
175
+ {assigning ? <SignatureIdentityForm key={assigning.id} mailbox={assigning} onDone={() => { setAssigning(null); setOutcome(`Queued the first install for ${assigning.email}.`); }} onCancel={() => setAssigning(null)} /> : null}
176
+ </FormSheet>
177
+ <FormSheet open={sourcing !== null} onOpenChange={(o) => !o && setSourcing(null)} title={`What ${sourcing?.email ?? "this mailbox"} sends`} description="Choose where this mailbox gets its signature."
178
+ footer={<><Button variant="outline" onClick={() => setSourcing(null)}>Cancel</Button><Button disabled={srcChoice === "template" && srcTemplate === ""} onClick={() => sourcing && setPending({ kind: "source", mailbox: sourcing, source: srcChoice, templateId: srcTemplate })}>Continue</Button></>}>
179
+ <select aria-label="Signature source" className={`${SELECT} w-full`} value={srcChoice} onChange={(e) => setSrcChoice(e.target.value as SourceChoice)}>{SOURCE_CHOICES.map((s) => <option key={s.value} value={s.value}>{s.label}</option>)}</select>
180
+ {srcChoice === "template" ? <select aria-label="Template" className={`${SELECT} w-full`} value={srcTemplate} onChange={(e) => setSrcTemplate(e.target.value)}><option value="">Choose a template</option>{(templates.data ?? []).map((t) => <option key={t.id} value={t.id}>{templateName(t)}</option>)}</select> : null}
181
+ </FormSheet>
182
+ <ConfirmSpendDialog
183
+ open={pending !== null}
184
+ tier="external"
185
+ title={text?.title ?? ""}
186
+ affected={text?.count}
187
+ description={text?.body}
188
+ confirmLabel={text?.label}
189
+ confirming={run.loading}
190
+ onCancel={() => setPending(null)}
191
+ onConfirm={() => { if (pending) void run.mutate(pending); }}
192
+ />
193
+ </div>
194
+ );
195
+ }
@@ -0,0 +1,130 @@
1
+ "use client";
2
+
3
+ /**
4
+ * SignaturePolicy: the organisation-wide signature policy: whether signatures are managed at all, the default template,
5
+ * a template per department, whether employees may change the parts you leave unlocked, which variant cold sends use, and
6
+ * native installation.
7
+ *
8
+ * SDK operations, in order (the policy, template and member reads come from the shared hooks):
9
+ * 1. getSignatureSettings (GET /signatures/settings) read the stored policy and its version; the form shows only once it has loaded
10
+ * 2. updateSignatureSettings (PUT /signatures/settings) write REPLACES the policy and sends the version that was read (409 if someone saved first).
11
+ * It changes what every managed mailbox sends, so ConfirmSpendDialog (external) comes first, sized from the managed mailboxes.
12
+ * The payload is the stored policy's known keys plus the fields the person changed; a field that was never stored is left out so
13
+ * the API applies its own default instead of one invented here. Clearing the default template sends null, clearing a department
14
+ * removes it from the map. Turning management on needs a default template; enforcing needs every active mailbox assigned (the API checks).
15
+ */
16
+
17
+ import { useState } from "react";
18
+ import { Button, Skeleton } from "@graph8/sdk/ui";
19
+ import { ApiError } from "@/g8/components/api-error";
20
+ import { ConfirmSpendDialog } from "@/g8/components/confirm-spend-dialog";
21
+ import { SectionCard } from "@/g8/components/screen/section-card";
22
+ import { g8Put } from "@/g8/lib/g8-client";
23
+ import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
24
+ import { plural } from "@/g8/lib/plural";
25
+ import { useSignatureDeployments, useSignatureMembers, useSignaturePolicy, useSignatureTemplates } from "../hooks/use-signatures-data";
26
+ import { COLD_VARIANTS, POLICY_MODES, isConflict, isManaged, knownPolicy, templateName } from "../lib/signatures-types";
27
+ import type { PolicyData, PolicyMode, SignaturePolicy as Policy } from "../lib/signatures-types";
28
+
29
+ const SELECT = "border-input bg-background h-9 w-full rounded-md border px-2 text-sm disabled:opacity-50";
30
+
31
+ export function SignaturePolicy() {
32
+ const policy = useSignaturePolicy();
33
+ const [saved, setSaved] = useState(false);
34
+ if (policy.error && !policy.data) return <ApiError error={policy.error} title="Could not load the policy" onRetry={() => void policy.refetch()} />;
35
+ if (!policy.data) return <Skeleton className="h-40 w-full" aria-label="Loading the policy" />;
36
+ // A save changes the version, which remounts the form, so the "saved" note lives here.
37
+ return (
38
+ <div className="grid gap-2">
39
+ {saved ? <p role="status" className="text-sm">Policy saved.</p> : null}
40
+ <PolicyForm key={policy.data.version} policy={policy.data} onReload={() => void policy.refetch()} onSaved={() => setSaved(true)} />
41
+ </div>
42
+ );
43
+ }
44
+
45
+ function PolicyForm({ policy, onReload, onSaved }: { policy: Policy; onReload: () => void; onSaved: () => void }) {
46
+ const templates = useSignatureTemplates();
47
+ const members = useSignatureMembers();
48
+ const deployments = useSignatureDeployments();
49
+ const stored = knownPolicy(policy.data);
50
+ const [mode, setMode] = useState<PolicyMode>((stored.mode as PolicyMode) ?? "off");
51
+ const [defaultId, setDefaultId] = useState(stored.default_template_id ?? "");
52
+ const [byDept, setByDept] = useState<Record<string, string>>(stored.department_templates ?? {});
53
+ const [overrides, setOverrides] = useState(stored.allow_employee_overrides ?? true);
54
+ const [cold, setCold] = useState<"full" | "compact">(stored.cold_sends_variant ?? "compact");
55
+ const [native, setNative] = useState(stored.native_enabled ?? false);
56
+ const [confirm, setConfirm] = useState(false);
57
+ const managed = (deployments.data ?? []).filter((d) => isManaged(d)).length;
58
+ const departments = [...new Set([...(members.data ?? []).map((m) => m.department).filter((d): d is string => Boolean(d)), ...Object.keys(stored.department_templates ?? {})])].sort();
59
+ const needsDefault = mode !== "off" && defaultId === "";
60
+
61
+ const next: Partial<PolicyData> = {
62
+ ...stored,
63
+ ...(mode !== stored.mode ? { mode } : {}),
64
+ ...(defaultId !== (stored.default_template_id ?? "") ? { default_template_id: defaultId === "" ? null : defaultId } : {}),
65
+ ...(JSON.stringify(byDept) !== JSON.stringify(stored.department_templates ?? {}) ? { department_templates: byDept } : {}),
66
+ ...(overrides !== (stored.allow_employee_overrides ?? true) ? { allow_employee_overrides: overrides } : {}),
67
+ ...(cold !== (stored.cold_sends_variant ?? "compact") ? { cold_sends_variant: cold } : {}),
68
+ ...(native !== (stored.native_enabled ?? false) ? { native_enabled: native } : {}),
69
+ };
70
+ const changed = JSON.stringify(next) !== JSON.stringify(stored);
71
+
72
+ const save = useG8Mutation(async (): Promise<Policy> => (await g8Put<Policy>("/signatures/settings", { version: policy.version, data: next })).data, {
73
+ onSuccess: () => { setConfirm(false); onSaved(); },
74
+ onError: () => setConfirm(false),
75
+ invalidates: [["signatures", "policy"], ["signatures", "deployments"]],
76
+ });
77
+
78
+ const setDept = (dept: string, id: string): void => {
79
+ const rest = Object.fromEntries(Object.entries(byDept).filter(([k]) => k !== dept));
80
+ setByDept(id === "" ? rest : { ...rest, [dept]: id });
81
+ };
82
+ const options = (templates.data ?? []).map((t) => <option key={t.id} value={t.id}>{templateName(t)}</option>);
83
+
84
+ return (
85
+ <SectionCard title="Signature policy" description={`Applies to ${plural(managed, "managed mailbox", "managed mailboxes")}.`}>
86
+ <form className="grid gap-4" onSubmit={(e) => { e.preventDefault(); if (changed && !needsDefault) setConfirm(true); }}>
87
+ <fieldset className="grid gap-2 text-sm">
88
+ <legend className="mb-1 font-medium">Management</legend>
89
+ {POLICY_MODES.map((m) => (
90
+ <label key={m.value} className="flex items-start gap-2"><input type="radio" name="g8-sig-mode" className="mt-1" checked={mode === m.value} onChange={() => setMode(m.value)} /><span><span className="font-medium">{m.label}</span><span className="text-muted-foreground block text-xs">{m.hint}</span></span></label>
91
+ ))}
92
+ </fieldset>
93
+ <label className="grid gap-1 text-sm">Default template
94
+ <select aria-label="Default template" className={SELECT} value={defaultId} onChange={(e) => setDefaultId(e.target.value)}><option value="">None</option>{options}</select>
95
+ </label>
96
+ {needsDefault ? <p role="alert" className="text-error text-xs">Choose a default template before turning management on.</p> : null}
97
+ {departments.length > 0 ? (
98
+ <fieldset className="grid gap-2">
99
+ <legend className="mb-1 text-sm font-medium">Template by department</legend>
100
+ {departments.map((d) => (
101
+ <label key={d} className="grid grid-cols-[10rem_1fr] items-center gap-2 text-sm">{d}
102
+ <select aria-label={`Template for ${d}`} className={SELECT} value={byDept[d] ?? ""} onChange={(e) => setDept(d, e.target.value)}><option value="">The default template</option>{options}</select>
103
+ </label>
104
+ ))}
105
+ </fieldset>
106
+ ) : null}
107
+ <label className="flex items-center gap-2 text-sm"><input type="checkbox" checked={overrides} onChange={(e) => setOverrides(e.target.checked)} />Employees may change the parts of their signature that are not locked</label>
108
+ <label className="grid gap-1 text-sm">Cold sends use
109
+ <select aria-label="Cold sends variant" className={SELECT} value={cold} onChange={(e) => setCold(e.target.value as "full" | "compact")}>{COLD_VARIANTS.map((v) => <option key={v.value} value={v.value}>{v.label}</option>)}</select>
110
+ </label>
111
+ <label className="flex items-center gap-2 text-sm"><input type="checkbox" checked={native} onChange={(e) => setNative(e.target.checked)} />Allow installing signatures inside the mail account (native installation)</label>
112
+ {save.error && isConflict(save.error) ? (
113
+ <div className="grid gap-2"><ApiError error={save.error} title="The policy changed since you opened it" /><div><Button type="button" size="sm" variant="outline" onClick={onReload}>Load the newer policy</Button></div></div>
114
+ ) : save.error ? <ApiError error={save.error} title="Could not save the policy" /> : null}
115
+ <div><Button type="submit" loading={save.loading} disabled={!changed || needsDefault}>Save policy</Button></div>
116
+ </form>
117
+ <ConfirmSpendDialog
118
+ open={confirm}
119
+ tier="external"
120
+ title={`Save the policy for ${plural(managed, "managed mailbox", "managed mailboxes")}?`}
121
+ affected={managed}
122
+ description="The policy decides which signature each managed mailbox sends and whether employees can change it. Turning management off stops managing every one of them."
123
+ confirmLabel="Save policy"
124
+ confirming={save.loading}
125
+ onCancel={() => setConfirm(false)}
126
+ onConfirm={() => void save.mutate(undefined)}
127
+ />
128
+ </SectionCard>
129
+ );
130
+ }
@@ -0,0 +1,119 @@
1
+ "use client";
2
+
3
+ /**
4
+ * SafeSignatureFrame and TemplatePreview: show what a signature will look like.
5
+ *
6
+ * SafeSignatureFrame renders signature HTML (which comes from the API, and a template can carry custom markup) inside a
7
+ * sandboxed iframe with no scripts, no same-origin access and a content-security-policy that allows only inline styles and
8
+ * https or data images. The markup is never put into the page's own DOM, so it cannot run code or restyle the app.
9
+ *
10
+ * TemplatePreview renders a template with the signature renderer, read-only (nothing is saved or sent):
11
+ * 1. previewSignatureTemplate (POST /signatures/preview) write* an UNSAVED draft; waits until the draft is valid, debounced
12
+ * 2. previewSavedSignatureTemplate (POST /signatures/templates/{id}/preview) write* a stored template
13
+ * *The contract tiers a preview as a write because it is a POST; it changes nothing. Pick an employee AND a mailbox to render
14
+ * with that person's real details; without both, the API renders sample data. Variants: full, compact (what cold sends use)
15
+ * and with the campaign banner, plus the plain-text version.
16
+ */
17
+
18
+ import { useMemo, useState } from "react";
19
+ import { Button, Skeleton } from "@graph8/sdk/ui";
20
+ import { ApiError } from "@/g8/components/api-error";
21
+ import { g8Post } from "@/g8/lib/g8-client";
22
+ import { useG8Query } from "@/g8/hooks/use-g8-query";
23
+ import { useDebounced } from "@/g8/lib/use-debounced-value";
24
+ import { useSignatureMailboxes, useSignatureMembers } from "../hooks/use-signatures-data";
25
+ import { memberLabel } from "../lib/signatures-types";
26
+ import type { PreviewVariant, TemplateData, TemplatePreview as TemplatePreviewData } from "../lib/signatures-types";
27
+
28
+ const POLICY = "default-src 'none'; img-src https: data:; style-src 'unsafe-inline'";
29
+ const SELECT = "border-input bg-background h-9 w-full rounded-md border px-2 text-sm";
30
+
31
+ /** The document the frame shows: the signature's markup between fixed, trusted wrapper tags. */
32
+ export function frameDocument(html: string): string {
33
+ return `<!doctype html><html><head><meta charset="utf-8"><meta http-equiv="Content-Security-Policy" content="${POLICY}"><style>body{margin:12px;font:14px/1.4 Arial,sans-serif;color:#222;background:#fff}</style></head><body>${html}</body></html>`;
34
+ }
35
+
36
+ export interface SafeSignatureFrameProps {
37
+ html: string;
38
+ title?: string;
39
+ className?: string;
40
+ }
41
+
42
+ export function SafeSignatureFrame({ html, title = "Signature preview", className }: SafeSignatureFrameProps) {
43
+ const doc = useMemo(() => frameDocument(html), [html]);
44
+ // sandbox="" is the strictest setting: no scripts, no forms, no popups, no same-origin. Do not add allow-* tokens.
45
+ return <iframe title={title} sandbox="" referrerPolicy="no-referrer" srcDoc={doc} className={className ?? "border-border h-56 w-full rounded-md border bg-white"} />;
46
+ }
47
+
48
+ const VARIANTS: { value: PreviewVariant; label: string }[] = [
49
+ { value: "full", label: "Full" },
50
+ { value: "compact", label: "Compact (cold sends)" },
51
+ { value: "campaign", label: "With campaign banner" },
52
+ ];
53
+
54
+ export interface TemplatePreviewProps {
55
+ /** An unsaved draft to render. Omit to render the stored template `templateId`. */
56
+ draft?: TemplateData;
57
+ /** Hold a draft back until it passes the template rules (the API refuses an invalid one). */
58
+ draftValid?: boolean;
59
+ templateId?: string;
60
+ className?: string;
61
+ }
62
+
63
+ export function TemplatePreview({ draft, draftValid = true, templateId, className }: TemplatePreviewProps) {
64
+ const members = useSignatureMembers();
65
+ const mailboxes = useSignatureMailboxes();
66
+ const [memberId, setMemberId] = useState("");
67
+ const [mailboxId, setMailboxId] = useState("");
68
+ const [variant, setVariant] = useState<PreviewVariant>("full");
69
+ const [plain, setPlain] = useState(false);
70
+ const real = memberId !== "" && mailboxId !== "";
71
+ const who = real ? { mailbox_id: Number(mailboxId), member_id: memberId } : {};
72
+ const body = JSON.stringify(draft ? { data: draft, ...who } : who);
73
+ const settled = useDebounced(body, draft ? 600 : 0);
74
+ const ready = (draft ? draftValid : Boolean(templateId)) && settled === body;
75
+
76
+ const result = useG8Query(
77
+ (signal) => {
78
+ const payload = JSON.parse(settled) as Record<string, unknown>;
79
+ const path = draft ? "/signatures/preview" : `/signatures/templates/${encodeURIComponent(templateId ?? "")}/preview`;
80
+ return g8Post<TemplatePreviewData>(path, payload, { signal }).then((r) => r.data);
81
+ },
82
+ [settled, templateId, Boolean(draft)],
83
+ { enabled: ready },
84
+ );
85
+ const shown = result.data?.[variant];
86
+
87
+ return (
88
+ <div className={className ?? "grid gap-3"}>
89
+ <div className="grid gap-2 sm:grid-cols-2">
90
+ <label className="grid gap-1 text-xs">
91
+ Show it for
92
+ <select aria-label="Preview for employee" className={SELECT} value={memberId} onChange={(e) => setMemberId(e.target.value)}>
93
+ <option value="">Sample employee</option>
94
+ {(members.data ?? []).map((m) => <option key={m.id} value={m.id}>{memberLabel(m)}</option>)}
95
+ </select>
96
+ </label>
97
+ <label className="grid gap-1 text-xs">
98
+ From mailbox
99
+ <select aria-label="Preview from mailbox" className={SELECT} value={mailboxId} onChange={(e) => setMailboxId(e.target.value)}>
100
+ <option value="">Sample mailbox</option>
101
+ {mailboxes.mailboxes.map((m) => <option key={m.id} value={m.id}>{m.email}</option>)}
102
+ </select>
103
+ </label>
104
+ </div>
105
+ {(memberId === "") !== (mailboxId === "") ? <p role="note" className="text-muted-foreground text-xs">Pick both an employee and a mailbox to preview with real details; until then the sample is shown.</p> : null}
106
+ <div className="flex flex-wrap items-center gap-1" role="group" aria-label="Preview variant">
107
+ {VARIANTS.map((v) => <Button key={v.value} type="button" size="sm" variant={variant === v.value ? "default" : "outline"} aria-pressed={variant === v.value} onClick={() => setVariant(v.value)}>{v.label}</Button>)}
108
+ <Button type="button" size="sm" variant={plain ? "default" : "outline"} aria-pressed={plain} onClick={() => setPlain(!plain)}>Plain text</Button>
109
+ </div>
110
+ {!ready && draft && !draftValid ? <p role="note" className="text-muted-foreground text-sm">The preview appears once the template is valid.</p> : result.error ? (
111
+ <ApiError error={result.error} title="Could not render the preview" onRetry={() => void result.refetch()} />
112
+ ) : result.loading && !result.data ? (
113
+ <Skeleton className="h-56 w-full" aria-label="Rendering the preview" />
114
+ ) : shown ? (
115
+ plain ? <pre aria-label="Plain text signature" className="bg-muted max-h-56 overflow-auto whitespace-pre-wrap rounded-md border p-3 text-sm">{shown.plain}</pre> : <SafeSignatureFrame html={shown.html} />
116
+ ) : null}
117
+ </div>
118
+ );
119
+ }