@anthusai/papyrus 5.43.0 → 5.44.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 (43) hide show
  1. package/amplify/functions/category-action/handler.ts +2 -1
  2. package/amplify/functions/monitoring-view/schema.js +8 -0
  3. package/amplify/functions/monitoring-view/schema.ts +8 -0
  4. package/amplify/functions/procedure-action/handler.ts +2 -10
  5. package/amplify/functions/proposal-actions/proposals.py +20 -3
  6. package/amplify/plugins/reader-steering/function/handler.py +1 -3
  7. package/amplify/plugins/reader-steering/function/moderation.py +19 -0
  8. package/bin/papyrus-infra.mjs +21 -1
  9. package/components/dev-sandbox-editor-sign-in.tsx +2 -2
  10. package/components/monitoring-overview-view.tsx +95 -19
  11. package/components/monitoring-panel.tsx +4 -2
  12. package/components/news-desk-client-provider.tsx +8 -2
  13. package/components/news-desk-taxonomy-client.ts +2 -1
  14. package/components/newsroom-ops-overview.tsx +6 -1
  15. package/components/newsroom-references-view.tsx +2 -4
  16. package/components/notification-center.tsx +345 -63
  17. package/components/papyrus-console-shell.tsx +2 -1
  18. package/components/proposals-panel.tsx +21 -24
  19. package/components/proposals-view.tsx +4 -2
  20. package/components/reference-review-card.tsx +80 -63
  21. package/infra/amplify-app-shell.js +18 -2
  22. package/infra/amplify-app-shell.ts +18 -2
  23. package/infra/chat-stack.js +222 -0
  24. package/infra/chat-stack.ts +250 -0
  25. package/infra/chat.test.js +142 -0
  26. package/infra/chat.test.ts +151 -0
  27. package/infra/index.js +1 -0
  28. package/infra/site-config.js +58 -1
  29. package/infra/site-config.ts +78 -1
  30. package/lib/monitoring-client.ts +14 -1
  31. package/lib/monitoring-overview.ts +33 -1
  32. package/lib/newsroom-chat-identity.ts +2 -1
  33. package/lib/newsroom-organizer-nav.ts +1 -0
  34. package/lib/proposals-client.ts +29 -0
  35. package/lib/reader-subject.ts +3 -2
  36. package/lib/review-client.ts +38 -1
  37. package/lib/review-drafts.ts +231 -0
  38. package/lib/review-fixture.ts +36 -2
  39. package/lib/review-requests.ts +64 -24
  40. package/lib/staff-groups.ts +23 -0
  41. package/lib/staging-gate.ts +2 -2
  42. package/package.json +4 -1
  43. package/routes.manifest.json +1 -1
@@ -24,6 +24,7 @@ import {
24
24
  requestDelegation,
25
25
  type DelegationStore,
26
26
  } from "../shared/curation-delegation-flow";
27
+ import { claimGroups } from "../../../lib/staff-groups";
27
28
 
28
29
  type ReviewHandler = Schema["reviewSteeringProposal"]["functionHandler"];
29
30
  type ReferenceCurationHandler = Schema["reviewReferenceCuration"]["functionHandler"];
@@ -360,7 +361,7 @@ function requireSignedInPerson(event: { identity?: unknown }): { sub: string; la
360
361
  }
361
362
  const identity = event.identity as { sub?: unknown; claims?: Record<string, unknown> };
362
363
  const claims = identity.claims ?? {};
363
- const groups = Array.isArray(claims["cognito:groups"]) ? (claims["cognito:groups"] as unknown[]) : [];
364
+ const groups = claimGroups(claims["cognito:groups"]);
364
365
  return {
365
366
  sub: normalizeRequiredString(claims.sub ?? identity.sub, "identity.sub"),
366
367
  label: normalizeOptionalString(claims.email) ?? normalizeOptionalString(claims.name) ?? "Newsroom editor",
@@ -26,6 +26,14 @@ export const monitoringViewSchemaDefinitions = {
26
26
  .returns(a.json())
27
27
  .authorization((allow) => [allow.groups(editors)])
28
28
  .handler(functionHandler("monitoringView", monitoringView)),
29
+ // Accept ("release") or Reject ("discard") one held email submission. The function validates the
30
+ // signed-in editor and that the message is routed to this publication; the receiver applies it.
31
+ monitoringDecideEmailSubmission: a
32
+ .mutation()
33
+ .arguments({ receiptId: a.string().required(), decision: a.string().required(), reason: a.string() })
34
+ .returns(a.json())
35
+ .authorization((allow) => [allow.groups(editors)])
36
+ .handler(functionHandler("monitoringView", monitoringView)),
29
37
  monitoringResolveEmailArticleLink: a
30
38
  .mutation()
31
39
  .arguments({ receiptId: a.string().required(), candidateIndex: a.integer().required() })
@@ -28,6 +28,14 @@ export const monitoringViewSchemaDefinitions = {
28
28
  .returns(a.json())
29
29
  .authorization((allow) => [allow.groups(editors)])
30
30
  .handler(functionHandler("monitoringView", monitoringView)),
31
+ // Accept ("release") or Reject ("discard") one held email submission. The function validates the
32
+ // signed-in editor and that the message is routed to this publication; the receiver applies it.
33
+ monitoringDecideEmailSubmission: a
34
+ .mutation()
35
+ .arguments({ receiptId: a.string().required(), decision: a.string().required(), reason: a.string() })
36
+ .returns(a.json())
37
+ .authorization((allow) => [allow.groups(editors)])
38
+ .handler(functionHandler("monitoringView", monitoringView)),
31
39
  monitoringResolveEmailArticleLink: a
32
40
  .mutation()
33
41
  .arguments({ receiptId: a.string().required(), candidateIndex: a.integer().required() })
@@ -3,6 +3,7 @@ import { getAmplifyDataClientConfig } from "@aws-amplify/backend/function/runtim
3
3
  import { Amplify } from "aws-amplify";
4
4
  import { generateClient } from "aws-amplify/data";
5
5
  import type { Schema } from "../../data/resource";
6
+ import { claimGroups } from "../../../lib/staff-groups";
6
7
 
7
8
  type DataClient = ReturnType<typeof generateClient<Schema>>;
8
9
  type DataClientErrors = Array<{ message?: string | null } | string | null> | null | undefined;
@@ -474,20 +475,11 @@ function validateJsonSchemaInput(schema: Record<string, unknown>, input: Record<
474
475
 
475
476
  function requireAdmin(event: ProcedureEvent): void {
476
477
  const claims = objectValue(event.identity?.claims);
477
- const groups = [
478
- ...normalizeClaimGroups(claims["cognito:groups"]),
479
- ...normalizeClaimGroups(claims.groups),
480
- ];
478
+ const groups = [...claimGroups(claims["cognito:groups"]), ...claimGroups(claims.groups)];
481
479
  if (groups.includes("admin")) return;
482
480
  throw new Error("Admin role required.");
483
481
  }
484
482
 
485
- function normalizeClaimGroups(value: unknown): string[] {
486
- if (Array.isArray(value)) return value.map((entry) => String(entry).trim()).filter(Boolean);
487
- if (typeof value === "string") return value.split(",").map((entry) => entry.trim()).filter(Boolean);
488
- return [];
489
- }
490
-
491
483
  async function getDataClient(): Promise<DataClient> {
492
484
  if (!clientPromise) {
493
485
  clientPromise = (async () => {
@@ -92,6 +92,25 @@ def is_appsync_event(event: dict[str, Any]) -> bool:
92
92
  return "arguments" in event and ("info" in event or event_field_name(event) is not None)
93
93
  PROPOSER_AI = "ai"
94
94
  STAFF_GROUPS = frozenset({"editor", "admin"})
95
+
96
+ _GROUP_SEPARATORS = re.compile(r"[\s,]+")
97
+
98
+
99
+ def claim_groups(value: Any) -> list[str]:
100
+ """Copy of ``papyrus_content.staff_groups.claim_groups`` (this function ships without that package; a test keeps them equal)."""
101
+ if isinstance(value, (list, tuple, set, frozenset)):
102
+ raw = [item for item in value if isinstance(item, str)]
103
+ elif isinstance(value, str):
104
+ raw = _GROUP_SEPARATORS.split(value)
105
+ else:
106
+ return []
107
+ cleaned = (item.strip().strip("\"'[](){}").strip() for item in raw)
108
+ return [item for item in cleaned if item]
109
+
110
+
111
+ def has_editor_rights(groups: Any) -> bool:
112
+ """``admin`` has every editor right: an admin who is not in the ``editor`` group is staff."""
113
+ return any(group.lower() in STAFF_GROUPS for group in claim_groups(groups))
95
114
  #: The only moves an operator's IAM role may make, as (from, to). Everything after the draft is an editor's decision.
96
115
  OPERATOR_TRANSITIONS = frozenset({(DRAFT, IN_STEERING), (DRAFT, DECLINED)})
97
116
  BY_STAFF, BY_OPERATOR = "staff", "operator"
@@ -140,10 +159,8 @@ def staff_subject(identity: dict[str, Any]) -> str | None:
140
159
  """The Cognito subject of an editor or admin, or ``None`` for anyone else."""
141
160
  claims = identity.get("claims") or {}
142
161
  groups = claims.get("cognito:groups") or identity.get("groups") or []
143
- if isinstance(groups, str):
144
- groups = [group.strip() for group in groups.strip("[]").replace(",", " ").split()]
145
162
  subject = identity.get("sub")
146
- if not isinstance(subject, str) or not subject or not STAFF_GROUPS.intersection(groups):
163
+ if not isinstance(subject, str) or not subject or not has_editor_rights(groups):
147
164
  return None
148
165
  return subject
149
166
 
@@ -1468,10 +1468,8 @@ def handle_staff_event(event: dict[str, Any], *, store: Any, settings: ReaderSet
1468
1468
  identity = event.get("identity") or {}
1469
1469
  claims = identity.get("claims") or {}
1470
1470
  groups = claims.get("cognito:groups") or identity.get("groups") or []
1471
- if isinstance(groups, str):
1472
- groups = [group.strip() for group in groups.strip("[]").replace(",", " ").split()]
1473
1471
  subject = identity.get("sub")
1474
- if not isinstance(subject, str) or not subject or not moderation_rules.STAFF_GROUPS.intersection(groups):
1472
+ if not isinstance(subject, str) or not subject or not moderation_rules.has_editor_rights(groups):
1475
1473
  return moderation_rules.refused("not_staff", "Only editors and admins can moderate.").to_dict()
1476
1474
  arguments = event.get("arguments") or {}
1477
1475
  outcome = moderation_rules.apply_action(
@@ -37,6 +37,25 @@ READER_ID = re.compile(r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f
37
37
  ACTION_ID = re.compile(r"mod-[0-9a-f]{32}")
38
38
  MAXIMUM_REASON = 500
39
39
  STAFF_GROUPS = frozenset({"editor", "admin"})
40
+
41
+ _GROUP_SEPARATORS = re.compile(r"[\s,]+")
42
+
43
+
44
+ def claim_groups(value: Any) -> list[str]:
45
+ """Copy of ``papyrus_content.staff_groups.claim_groups`` (this function ships without that package; a test keeps them equal)."""
46
+ if isinstance(value, (list, tuple, set, frozenset)):
47
+ raw = [item for item in value if isinstance(item, str)]
48
+ elif isinstance(value, str):
49
+ raw = _GROUP_SEPARATORS.split(value)
50
+ else:
51
+ return []
52
+ cleaned = (item.strip().strip("\"'[](){}").strip() for item in raw)
53
+ return [item for item in cleaned if item]
54
+
55
+
56
+ def has_editor_rights(groups: Any) -> bool:
57
+ """``admin`` has every editor right: an admin who is not in the ``editor`` group is staff."""
58
+ return any(group.lower() in STAFF_GROUPS for group in claim_groups(groups))
40
59
  ITEM_ID_PREFIX = "item-"
41
60
 
42
61
 
@@ -3,6 +3,7 @@
3
3
  * papyrus-infra synth --site infra/site.json [--out cdk.out] [--account A] [--region R]
4
4
  * papyrus-infra synth --account-stack github-oidc [--out cdk.out] [--account A] [--region R]
5
5
  * papyrus-infra print-buildspec --site infra/site.json --app cms-production|cms-staging|reader
6
+ * papyrus-infra synth --site infra/site.json --part chat [--chat-lambdas DIR] [--out cdk.out] (the private chat data stack)
6
7
  * papyrus-infra deploy --site infra/site.json [--only-branch main] [--only-app-id ID] [--out cdk.out] [--account A] [--region R]
7
8
  * Synthesizes the app-shell stack for a publication from its own site.json,
8
9
  * prints one of the generated Amplify build specs, or deploys the stack from
@@ -16,6 +17,7 @@ import { resolveCdkEnvironment } from "../src/cdk-environment.mjs";
16
17
 
17
18
  const USAGE = [
18
19
  "Usage: papyrus-infra synth --site infra/site.json [--out cdk.out] [--account A] [--region R]",
20
+ " papyrus-infra synth --site infra/site.json --part chat [--chat-lambdas DIR] [--out cdk.out] [--account A] [--region R]",
19
21
  " papyrus-infra synth --account-stack github-oidc [--out cdk.out] [--account A] [--region R]",
20
22
  " papyrus-infra print-buildspec --site infra/site.json --app cms-production|cms-staging|reader",
21
23
  " papyrus-infra deploy-edge --stack NAME --template FILE [--adoption-template FILE --adopt-resources FILE] [--parameter KEY=VALUE]... [--delete-function NAME] [--only-branch main] [--only-app-id ID] [--region R]",
@@ -186,8 +188,9 @@ if (command === "deploy") {
186
188
  }
187
189
 
188
190
  let AmplifyAppShellStack;
191
+ let PapyrusChatStack;
189
192
  try {
190
- ({ AmplifyAppShellStack } = await import("../infra/index.js"));
193
+ ({ AmplifyAppShellStack, PapyrusChatStack } = await import("../infra/index.js"));
191
194
  } catch (error) {
192
195
  console.error(`papyrus-infra: ${error instanceof Error ? error.message : error}`);
193
196
  process.exit(1);
@@ -195,6 +198,23 @@ try {
195
198
 
196
199
  const { App } = createRequire(path.join(process.cwd(), "noop.js"))("aws-cdk-lib");
197
200
  const app = new App({ outdir: path.resolve(opt("out", "cdk.out")) });
201
+ if (opt("part", "shell") === "chat") {
202
+ // The site's private newsroom chat data stack (docs/private-newsroom-chat-per-site.md); deployed before the app shell enables chat.
203
+ if (!config.chat?.enabled) {
204
+ console.error(`papyrus-infra: site ${config.siteId} has no enabled chat block`);
205
+ process.exit(1);
206
+ }
207
+ const lambdaAssetDirectory = opt("chat-lambdas");
208
+ const chatStack = new PapyrusChatStack(app, {
209
+ config,
210
+ env: resolveEnvironmentOrExit(),
211
+ lambdaAssetDirectory: lambdaAssetDirectory ? path.resolve(lambdaAssetDirectory) : undefined,
212
+ description: `Private Papyrus newsroom chat data plane for ${config.siteId}.`,
213
+ });
214
+ app.synth();
215
+ console.log(`synthesized ${chatStack.stackName} -> ${path.resolve(opt("out", "cdk.out"))}`);
216
+ process.exit(0);
217
+ }
198
218
  new AmplifyAppShellStack(app, `AmplifyAppShell-${config.siteId}`, config, {
199
219
  env: resolveEnvironmentOrExit(),
200
220
  stackName: resolveStackName(config),
@@ -2,9 +2,9 @@
2
2
 
3
3
  import { fetchAuthSession, signIn, signOut } from "aws-amplify/auth";
4
4
  import { configureAmplifyClient } from "./amplify-client-provider";
5
+ import { hasEditorRights } from "../lib/staff-groups";
5
6
  import type { DevSandboxEditorAuth } from "../lib/dev-sandbox-editor-auth";
6
7
 
7
- const EDITOR_GROUPS = new Set(["editor", "admin"]);
8
8
 
9
9
  function readSessionGroups(session: Awaited<ReturnType<typeof fetchAuthSession>>): string[] {
10
10
  const accessPayload = session.tokens?.accessToken?.payload ?? {};
@@ -29,7 +29,7 @@ function readSessionGroups(session: Awaited<ReturnType<typeof fetchAuthSession>>
29
29
 
30
30
  function sessionHasEditorAccess(session: Awaited<ReturnType<typeof fetchAuthSession>>): boolean {
31
31
  if (!session.tokens?.accessToken) return false;
32
- return readSessionGroups(session).some((group) => EDITOR_GROUPS.has(group));
32
+ return hasEditorRights(readSessionGroups(session));
33
33
  }
34
34
 
35
35
  export async function ensureDevSandboxEditorSession(
@@ -1,5 +1,6 @@
1
1
  import { useEffect, useRef, useState } from "react";
2
2
  import Link from "next/link";
3
+ import { CheckIcon, XIcon } from "lucide-react";
3
4
  import { getNewsroomNavHref } from "../lib/newsroom-nav";
4
5
  import {
5
6
  confidenceText,
@@ -12,6 +13,10 @@ import {
12
13
  beginEmailSubmissionAction,
13
14
  endEmailSubmissionAction,
14
15
  isCurrentEmailSubmissionAction,
16
+ emailSubmissionAccepted,
17
+ QUARANTINE_LABELS,
18
+ type EmailDecisionResult,
19
+ type EmailQuarantine,
15
20
  type MonitoringItem,
16
21
  type MonitoringEmailDetails,
17
22
  type MonitoringEmailArticleResolution,
@@ -97,8 +102,9 @@ function QueueItem({ item, ownId, demo }: { item: MonitoringItem; ownId: string
97
102
  );
98
103
  }
99
104
 
100
- export function EmailSubmissions({ items, detail, onOpen, onResolveArticleLink, onImportCandidates }: {
105
+ export function EmailSubmissions({ items, detail, onOpen, onResolveArticleLink, onImportCandidates, onDecide }: {
101
106
  items: MonitoringEmailSummary[]; detail: MonitoringEmailDetails | null; onOpen?: (receiptId: string) => void;
107
+ onDecide?: (receiptId: string, decision: "accept" | "reject", reason: string) => Promise<EmailDecisionResult>;
102
108
  onResolveArticleLink?: (receiptId: string, candidateIndex: number) => Promise<MonitoringEmailArticleResolution>;
103
109
  onImportCandidates?: (receiptId: string, items: EmailReferenceImportItem[]) => Promise<EmailReferenceImportOutcome>;
104
110
  }) {
@@ -108,8 +114,15 @@ export function EmailSubmissions({ items, detail, onOpen, onResolveArticleLink,
108
114
  const [busyImportByReceipt, setBusyImportByReceipt] = useState<Record<string, boolean>>({});
109
115
  const [noticeByReceipt, setNoticeByReceipt] = useState<Record<string, string>>({});
110
116
  const [importedByReceipt, setImportedByReceipt] = useState<Record<string, EmailReferenceImportOutcome["references"]>>({});
117
+ const [decidedByReceipt, setDecidedByReceipt] = useState<Record<string, EmailQuarantine>>({});
118
+ const [reasonByReceipt, setReasonByReceipt] = useState<Record<string, string>>({});
119
+ const [busyDecisionByReceipt, setBusyDecisionByReceipt] = useState<Record<string, boolean>>({});
111
120
  const receiptId = detail && "submission" in detail ? detail.submission.receiptId : null;
112
121
  const receiptKey = receiptId ?? "";
122
+ const quarantine = (detail && "submission" in detail ? decidedByReceipt[receiptKey] ?? detail.submission.quarantine : null);
123
+ const accepted = emailSubmissionAccepted(quarantine);
124
+ const reason = reasonByReceipt[receiptKey] ?? "";
125
+ const busyDecision = busyDecisionByReceipt[receiptKey] ?? false;
113
126
  const selected = selectedByReceipt[receiptKey] ?? {};
114
127
  const resolved = resolvedByReceipt[receiptKey] ?? {};
115
128
  const busyIndex = busyIndexByReceipt[receiptKey] ?? null;
@@ -139,7 +152,7 @@ export function EmailSubmissions({ items, detail, onOpen, onResolveArticleLink,
139
152
  if (!isCurrentEmailSubmissionAction(activeReceiptId.current, requestReceiptId)) return;
140
153
  if (!("url" in result)) {
141
154
  const reason = "reason" in result && typeof result.reason === "string"
142
- && /^(?:redirect_[a-z_]+|resolution_record_invalid|resolution_not_recorded)$/.test(result.reason)
155
+ && /^(?:redirect_[a-z_]+|resolution_record_invalid|resolution_not_recorded|invalid_url|not_public|dns|timeout|connection|tls|loop|http_4xx|http_5xx|blocked|too_large|unreadable|unsafe_destination|no_query|search_unavailable|search_error|no_match)$/.test(result.reason)
143
156
  ? ` (${result.reason})` : "";
144
157
  setNoticeByReceipt((current) => ({ ...current, [requestReceiptId]: `The article destination could not be resolved safely${reason}. The original link remains private and unchanged.` }));
145
158
  return;
@@ -147,7 +160,9 @@ export function EmailSubmissions({ items, detail, onOpen, onResolveArticleLink,
147
160
  const candidate = detail && "submission" in detail ? detail.candidateLinks[index] : null;
148
161
  setResolvedByReceipt((current) => ({ ...current, [requestReceiptId]: { ...current[requestReceiptId], [index]: { url: result.url, title: result.title || candidate?.title || "Article" } } }));
149
162
  setSelectedByReceipt((current) => ({ ...current, [requestReceiptId]: { ...current[requestReceiptId], [index]: true } }));
150
- setNoticeByReceipt((current) => ({ ...current, [requestReceiptId]: "Resolved. This request may be recorded as a click by the newsletter sender." }));
163
+ setNoticeByReceipt((current) => ({ ...current, [requestReceiptId]: result.foundVia === "search"
164
+ ? `The newsletter link could not be followed, so this article was found by searching the headline (match ${Math.round((result.search?.matchScore ?? 0) * 100)}%). Check that it is the right article before importing it.`
165
+ : "Resolved. This request may be recorded as a click by the newsletter sender." }));
151
166
  } catch {
152
167
  if (isCurrentEmailSubmissionAction(activeReceiptId.current, requestReceiptId)) {
153
168
  setNoticeByReceipt((current) => ({ ...current, [requestReceiptId]: "The article destination could not be resolved. Try again later." }));
@@ -158,6 +173,34 @@ export function EmailSubmissions({ items, detail, onOpen, onResolveArticleLink,
158
173
  }
159
174
  };
160
175
 
176
+ const decide = async (decision: "accept" | "reject") => {
177
+ if (!receiptId || !onDecide) return;
178
+ const requestReceiptId = receiptId;
179
+ const actionKey = `decide:${requestReceiptId}`;
180
+ if (!beginEmailSubmissionAction(inFlightActions.current, actionKey)) return;
181
+ setBusyDecisionByReceipt((current) => ({ ...current, [requestReceiptId]: true }));
182
+ setNoticeByReceipt((current) => ({ ...current, [requestReceiptId]: "" }));
183
+ try {
184
+ const result = await onDecide(requestReceiptId, decision, reason.trim());
185
+ if ("ok" in result) {
186
+ setDecidedByReceipt((current) => ({ ...current, [requestReceiptId]: result.quarantine }));
187
+ setNoticeByReceipt((current) => ({ ...current, [requestReceiptId]: decision === "accept"
188
+ ? "Accepted. The receiver queues it within a minute; the links can be added to References once it is queued."
189
+ : "Rejected. The message is closed and kept privately until it expires." }));
190
+ } else if ("error" in result && result.quarantine) {
191
+ setDecidedByReceipt((current) => ({ ...current, [requestReceiptId]: result.quarantine as EmailQuarantine }));
192
+ setNoticeByReceipt((current) => ({ ...current, [requestReceiptId]: "Another decision was already recorded for this message; it cannot be changed." }));
193
+ } else {
194
+ setNoticeByReceipt((current) => ({ ...current, [requestReceiptId]: "The decision was not recorded. Check your editor access and try again." }));
195
+ }
196
+ } catch {
197
+ setNoticeByReceipt((current) => ({ ...current, [requestReceiptId]: "The decision was not recorded. It is safe to try again." }));
198
+ } finally {
199
+ endEmailSubmissionAction(inFlightActions.current, actionKey);
200
+ setBusyDecisionByReceipt((current) => ({ ...current, [requestReceiptId]: false }));
201
+ }
202
+ };
203
+
161
204
  const importSelected = async () => {
162
205
  if (!receiptId || !detail || !("submission" in detail) || !onImportCandidates) return;
163
206
  const requestReceiptId = receiptId;
@@ -210,6 +253,7 @@ export function EmailSubmissions({ items, detail, onOpen, onResolveArticleLink,
210
253
  <span>SES signals — SPF {item.verdicts.spf}, DKIM {item.verdicts.dkim}, DMARC {item.verdicts.dmarc}; spam {item.verdicts.spam}, virus {item.verdicts.virus}</span>
211
254
  {item.warnings.length ? <span>Warnings: {item.warnings.join(", ")}</span> : null}
212
255
  {item.errorCode ? <span>State: {item.status} ({item.errorCode})</span> : <span>State: {item.status}</span>}
256
+ {item.quarantine ? <span data-email-quarantine-state={item.quarantine.state}>{QUARANTINE_LABELS[item.quarantine.state]}</span> : null}
213
257
  {onOpen ? <button type="button" onClick={() => onOpen(item.receiptId)}>Read email and review candidate links</button> : null}
214
258
  </li>)}
215
259
  </ul> : <p>No email submissions are available for this publication.</p>}
@@ -218,32 +262,60 @@ export function EmailSubmissions({ items, detail, onOpen, onResolveArticleLink,
218
262
  <h4>{detail.submission.subject || "Submitted email"}</h4>
219
263
  <p>Original content is shown as inert text. Sender and message content are untrusted; no instructions are executed.</p>
220
264
  <pre style={{ whiteSpace: "pre-wrap", overflowWrap: "anywhere" }}>{detail.safeBodyText}</pre>
265
+ {quarantine ? <section aria-label="Held for review" data-email-quarantine={quarantine.state}>
266
+ <h5>{QUARANTINE_LABELS[quarantine.state]}</h5>
267
+ {quarantine.state === "quarantined" ? <>
268
+ <p>This message was held privately and nothing was routed. Accept it to queue its links as if it had been accepted, or Reject it to close it. Sender identity and authentication signals never grant access; the decision is yours and is recorded.</p>
269
+ <label>Why? (optional)
270
+ <textarea value={reason} rows={3} maxLength={500} style={{ display: "block", width: "100%" }}
271
+ onChange={(event) => setReasonByReceipt((current) => ({ ...current, [receiptKey]: event.target.value }))} />
272
+ </label>
273
+ <button type="button" data-email-decision="accept" disabled={busyDecision || !onDecide} onClick={() => void decide("accept")}>
274
+ <CheckIcon aria-hidden="true" size={16} /> Accept
275
+ </button>
276
+ <button type="button" data-email-decision="reject" disabled={busyDecision || !onDecide} onClick={() => void decide("reject")}>
277
+ <XIcon aria-hidden="true" size={16} /> Reject
278
+ </button>
279
+ </> : null}
280
+ {quarantine.decision ? <p data-email-decision-record>
281
+ {quarantine.decision.action === "accept" ? "Accepted" : "Rejected"}
282
+ {quarantine.decision.by ? ` by ${quarantine.decision.by}` : ""}{quarantine.decision.at ? ` on ${formatUtc(quarantine.decision.at)}` : ""}
283
+ {quarantine.decision.reason ? `. Why: ${quarantine.decision.reason}` : "."}
284
+ </p> : null}
285
+ {quarantine.failure ? <p role="alert">The decision could not be applied ({quarantine.failure}).</p> : null}
286
+ {quarantine.state === "expired" ? <p>This message expired and can no longer be accepted.</p> : null}
287
+ {notice && !accepted ? <p role="status" data-email-decision-result>{notice}</p> : null}
288
+ </section> : null}
221
289
  <h5>Candidate references ({detail.candidateLinks.length})</h5>
222
290
  <ul>{detail.candidateLinks.map((link, index) => <li key={`${link.url}-${index}`}>
223
- {link.resolutionStatus === "unresolved" && !resolved[index]
291
+ {!accepted
292
+ ? <span>{link.title || "Source link"} <code>{link.url}</code></span>
293
+ : link.resolutionStatus === "unresolved" && !resolved[index]
224
294
  ? <span>{link.title || "Newsletter article link"} (tracking redirect not opened)</span>
225
295
  : <a href={(resolved[index]?.url ?? link.url)} target="_blank" rel="noreferrer noopener">{resolved[index]?.title ?? (link.title || "Source link")}</a>}
226
- <label>
296
+ {accepted ? <label>
227
297
  <input type="checkbox" checked={selected[index] ?? false}
228
298
  disabled={link.resolutionStatus === "unresolved" && !resolved[index]}
229
299
  onChange={(event) => setSelectedByReceipt((current) => ({ ...current, [receiptKey]: { ...current[receiptKey], [index]: event.target.checked } }))} />
230
300
  Add as pending Reference
231
- </label>
232
- {link.resolutionStatus === "unresolved" && !resolved[index] ? (
301
+ </label> : null}
302
+ {accepted && link.resolutionStatus === "unresolved" && !resolved[index] ? (
233
303
  <button type="button" disabled={busyIndex !== null || !onResolveArticleLink} onClick={() => void resolveLink(index)}>
234
304
  {busyIndex === index ? "Resolving…" : "Resolve article redirect (may register a click)"}
235
305
  </button>
236
306
  ) : null}
237
307
  </li>)}</ul>
238
- <p>Adding links creates or reuses pending References. It does not approve, label, classify, or publish them.</p>
239
- <button type="button" disabled={busyImport || !onImportCandidates || !detail.submission.corpusId}
240
- onClick={() => void importSelected()}>
241
- {busyImport ? "Adding References…" : "Add selected links to References"}
242
- </button>
243
- {notice ? <p role="status" data-email-import-result>{notice}</p> : null}
244
- {importedReferences.length ? <ul>{importedReferences.map((reference, index) => (
245
- <li key={`${reference.id}-${index}`}><Link href={getNewsroomNavHref(`/newsroom/references/${encodeURIComponent(reference.lineageId)}`, false)}>Review imported Reference {index + 1}</Link></li>
246
- ))}</ul> : null}
308
+ {accepted ? <>
309
+ <p>Adding links creates or reuses pending References. It does not approve, label, classify, or publish them.</p>
310
+ <button type="button" disabled={busyImport || !onImportCandidates || !detail.submission.corpusId}
311
+ onClick={() => void importSelected()}>
312
+ {busyImport ? "Adding References…" : "Add selected links to References"}
313
+ </button>
314
+ {notice ? <p role="status" data-email-import-result>{notice}</p> : null}
315
+ {importedReferences.length ? <ul>{importedReferences.map((reference, index) => (
316
+ <li key={`${reference.id}-${index}`}><Link href={getNewsroomNavHref(`/newsroom/references/${encodeURIComponent(reference.lineageId)}`, false)}>Review imported Reference {index + 1}</Link></li>
317
+ ))}</ul> : null}
318
+ </> : <p>Links stay inert text, and nothing can be added to References, until this message is accepted.</p>}
247
319
  </section>
248
320
  ) : detail && "error" in detail ? <p role="alert">You cannot read this submission.</p>
249
321
  : detail && "state" in detail ? <p role="alert">Submission details are unavailable.</p> : null}
@@ -251,8 +323,9 @@ export function EmailSubmissions({ items, detail, onOpen, onResolveArticleLink,
251
323
  );
252
324
  }
253
325
 
254
- function Ready({ data, demo, emailDetail, onEmailOpen, onResolveArticleLink, onImportCandidates }: {
326
+ function Ready({ data, demo, emailDetail, onEmailOpen, onResolveArticleLink, onImportCandidates, onDecide }: {
255
327
  data: MonitoringOverview; demo: boolean; emailDetail: MonitoringEmailDetails | null; onEmailOpen: (id: string) => void;
328
+ onDecide?: (receiptId: string, decision: "accept" | "reject", reason: string) => Promise<EmailDecisionResult>;
256
329
  onResolveArticleLink?: (receiptId: string, candidateIndex: number) => Promise<MonitoringEmailArticleResolution>;
257
330
  onImportCandidates?: (receiptId: string, items: EmailReferenceImportItem[]) => Promise<EmailReferenceImportOutcome>;
258
331
  }) {
@@ -265,7 +338,8 @@ function Ready({ data, demo, emailDetail, onEmailOpen, onResolveArticleLink, onI
265
338
 
266
339
  <EmailSubmissions items={data.emailSubmissions ?? []} detail={emailDetail} onOpen={onEmailOpen}
267
340
  onResolveArticleLink={demo ? undefined : onResolveArticleLink}
268
- onImportCandidates={demo ? undefined : onImportCandidates} />
341
+ onImportCandidates={demo ? undefined : onImportCandidates}
342
+ onDecide={demo ? undefined : onDecide} />
269
343
 
270
344
  <article className="news-desk-doctrine-card" data-monitoring-section="router">
271
345
  <div className="news-desk-doctrine-card__header"><div><h3>Router</h3><span>Sends collected items to the shared Cyclotron and delivers relevant ones to References</span></div></div>
@@ -373,6 +447,7 @@ export function MonitoringOverviewView({
373
447
  onEmailOpen = () => {},
374
448
  onResolveArticleLink,
375
449
  onImportCandidates,
450
+ onDecide,
376
451
  }: {
377
452
  result: MonitoringResult | null;
378
453
  canViewAll: boolean;
@@ -383,6 +458,7 @@ export function MonitoringOverviewView({
383
458
  onEmailOpen?: (receiptId: string) => void;
384
459
  onResolveArticleLink?: (receiptId: string, candidateIndex: number) => Promise<MonitoringEmailArticleResolution>;
385
460
  onImportCandidates?: (receiptId: string, items: EmailReferenceImportItem[]) => Promise<EmailReferenceImportOutcome>;
461
+ onDecide?: (receiptId: string, decision: "accept" | "reject", reason: string) => Promise<EmailDecisionResult>;
386
462
  }) {
387
463
  let body: React.ReactNode;
388
464
  if (result === null) {
@@ -399,7 +475,7 @@ export function MonitoringOverviewView({
399
475
  body = <Message kind="empty" title="Nothing has been collected yet">No feed is configured and no run has been recorded. Items appear here after the collector&apos;s first run.</Message>;
400
476
  } else {
401
477
  body = <Ready data={result} demo={demo} emailDetail={emailDetail} onEmailOpen={onEmailOpen}
402
- onResolveArticleLink={onResolveArticleLink} onImportCandidates={onImportCandidates} />;
478
+ onResolveArticleLink={onResolveArticleLink} onImportCandidates={onImportCandidates} onDecide={onDecide} />;
403
479
  }
404
480
  const scopeLine = result && "scope" in result
405
481
  ? (result.scope.mode === "all" ? "Showing the whole shared pipeline, every publication." : `Showing what is relevant to ${result.scope.publicationId ?? "this publication"}.`)
@@ -3,6 +3,7 @@
3
3
  import { useEffect, useState } from "react";
4
4
  import { useSearchParams } from "next/navigation";
5
5
  import {
6
+ decideMonitoringEmailSubmission,
6
7
  importMonitoringEmailCandidates,
7
8
  loadMonitoringEmailSubmission,
8
9
  loadMonitoringOverview,
@@ -39,7 +40,8 @@ function MonitoringEmailReceipt({ receiptId, isDemo }: { receiptId: string; isDe
39
40
  return <div data-news-desk-admin-panel="monitoring">
40
41
  {detail === null ? <p role="status">Loading email submission…</p> :
41
42
  <EmailSubmissions items={"submission" in detail ? [detail.submission] : []} detail={detail}
42
- onResolveArticleLink={resolveMonitoringEmailArticleLink} onImportCandidates={importMonitoringEmailCandidates} />}
43
+ onResolveArticleLink={resolveMonitoringEmailArticleLink} onImportCandidates={importMonitoringEmailCandidates}
44
+ onDecide={decideMonitoringEmailSubmission} />}
43
45
  </div>;
44
46
  }
45
47
 
@@ -86,5 +88,5 @@ function MonitoringOverviewPanel({ isAdmin, isDemo }: { isAdmin: boolean; isDemo
86
88
 
87
89
  return <MonitoringOverviewView result={result} canViewAll={isAdmin} mode={mode} onModeChange={setMode} demo={isDemo}
88
90
  emailDetail={emailDetail} onEmailOpen={isDemo ? () => {} : openEmail} onResolveArticleLink={isDemo ? undefined : resolveEmailArticleLink}
89
- onImportCandidates={importEmailCandidates} />;
91
+ onImportCandidates={importEmailCandidates} onDecide={isDemo ? undefined : decideMonitoringEmailSubmission} />;
90
92
  }
@@ -113,7 +113,8 @@ export function NewsDeskClientProvider({
113
113
  return;
114
114
  }
115
115
  if (isAuthorizationError(error)) {
116
- setShell((current) => resolveForbidden(current, access.auth));
116
+ // The session already has editor rights, so the backend refusing a read is not a missing role. Say what was refused.
117
+ setShell((current) => resolveDashboardFailure(current, access.auth, refusedReadMessage(error)));
117
118
  return;
118
119
  }
119
120
  setShell((current) => resolveDashboardFailure(
@@ -215,9 +216,14 @@ function isAuthorizationError(error: unknown): boolean {
215
216
  return /not authorized|unauthorized|access denied|forbidden/i.test(message);
216
217
  }
217
218
 
219
+ function refusedReadMessage(error: unknown): string {
220
+ const detail = error instanceof Error ? error.message : String(error ?? "");
221
+ return `The Newsroom backend refused a read for this signed-in account (${detail}). The account has editor or admin rights, so this is a backend authorization rule, not a missing role.`;
222
+ }
223
+
218
224
  function resolvePrivateReadFailure(state: NewsDeskShellState, auth: NewsDeskShellState["auth"], error: unknown, fallback: string): NewsDeskShellState {
219
225
  if (isUnauthenticatedError(error)) return resolveSignedOut(state, { status: "signedOut", label: "Signed out" });
220
- if (isAuthorizationError(error)) return resolveForbidden(state, auth);
226
+ if (isAuthorizationError(error)) return resolveDashboardFailure(state, auth, refusedReadMessage(error));
221
227
  return resolveDashboardFailure(state, auth, error instanceof Error ? error.message : fallback);
222
228
  }
223
229
 
@@ -1,6 +1,7 @@
1
1
  "use client";
2
2
 
3
3
  import { loadReferenceReviewDetails } from "../lib/reference-review-details";
4
+ import { hasEditorRights } from "../lib/staff-groups";
4
5
 
5
6
  import { isEditorialWorkAssignment } from "../lib/newsroom-assignments";
6
7
  import { isLocalPreviewEnabled } from "../lib/local-preview";
@@ -838,7 +839,7 @@ export async function loadEditorResolvedAccessState(): Promise<EditorAccessState
838
839
  if (!refreshed.hasSession || refreshed.auth.status === "signedOut") {
839
840
  return { status: "signedOut", isEditor: false, isAdmin: false, auth: refreshed.auth, error: null };
840
841
  }
841
- const isEditor = refreshed.groups.some((group) => group === "editor" || group === "admin");
842
+ const isEditor = hasEditorRights(refreshed.groups);
842
843
  if (!isEditor) {
843
844
  return {
844
845
  status: "forbidden",
@@ -24,6 +24,7 @@ import {
24
24
  import { newsroomInboxRowClassName } from "../lib/newsroom-list-selection";
25
25
  import type { RelevanceDecisionView } from "../lib/relevance-decisions";
26
26
  import { NotificationCenter } from "./notification-center";
27
+ import { CyclotronRow, predictionFromDecision } from "./reference-review-card";
27
28
 
28
29
  type NewsroomOpsOverviewProps = {
29
30
  assignments: AssignmentRecord[];
@@ -105,9 +106,13 @@ export function NewsroomOpsOverview({
105
106
  href={href}
106
107
  key={lineageId}
107
108
  >
108
- <div className="min-w-0 flex-1">
109
+ <div className="min-w-0 flex-1 space-y-1">
109
110
  <p className="truncate text-sm font-medium text-foreground">{referenceDisplayTitle(reference)}</p>
110
111
  <p className="text-xs text-muted-foreground">Review source intake · {formatReferenceListDate(reference)}</p>
112
+ <div data-needs-attention-suggestion={lineageId}>
113
+ <CyclotronRow prediction={decisions.get(lineageId) ? predictionFromDecision(decisions.get(lineageId)!) : { state: "none" }} />
114
+ </div>
115
+ <p className="text-xs font-medium text-foreground">Open it to Accept or Reject in the review queue</p>
111
116
  </div>
112
117
  <Badge className="mt-0.5 shrink-0" variant="outline">Pending</Badge>
113
118
  </Link>
@@ -44,6 +44,7 @@ import { newsroomListRowClassName } from "../lib/newsroom-list-selection";
44
44
  import { ReferenceSourcePreview } from "./reference-source-preview";
45
45
  import {
46
46
  ReviewCard,
47
+ predictionFromDecision,
47
48
  ReviewQueueBar,
48
49
  useQueueGestures,
49
50
  type CardPrediction,
@@ -269,10 +270,7 @@ function summaryOf(reference: ReferenceRecord): string | null {
269
270
  }
270
271
 
271
272
  function cardPrediction(decision: RelevanceDecisionView | undefined, predictionState: PredictionState): CardPrediction {
272
- if (decision) {
273
- const positive = decision.positiveLabel ?? "include";
274
- return { state: "ready", choice: decision.label === positive ? "accept" : "reject", confidence: decision.confidence };
275
- }
273
+ if (decision) return predictionFromDecision(decision);
276
274
  if (predictionState === "loading") return { state: "loading" };
277
275
  if (predictionState === "error") return { state: "error" };
278
276
  return { state: "none" };