@anthusai/papyrus 5.33.2 → 5.34.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.
- package/amplify/site-backend-config.ts +1 -1
- package/amplify/site-backend-names.js +4 -2
- package/amplify/site-backend-names.ts +4 -2
- package/amplify/site-backend.js +1 -1
- package/amplify/site-backend.ts +1 -1
- package/components/news-desk-client-provider.tsx +18 -18
- package/components/news-desk-taxonomy-client.ts +4 -7
- package/components/newsroom-access-gate.tsx +15 -1
- package/components/newsroom-article-editor.tsx +4 -2
- package/components/newsroom-articles-view.tsx +6 -6
- package/components/newsroom-board-view.tsx +8 -3
- package/components/newsroom-ops-overview.tsx +12 -100
- package/components/newsroom-ops-shell.tsx +2 -2
- package/components/newsroom-organizer.css +2 -0
- package/components/newsroom-overview-skeleton.tsx +25 -0
- package/components/notification-center.tsx +13 -2
- package/components/reader-auth-state.ts +1 -1
- package/components/topic-steering-workspace.tsx +1 -2
- package/infra/amplify-app-shell.js +4 -0
- package/infra/amplify-app-shell.ts +4 -0
- package/infra/cloud-email-config.js +32 -0
- package/infra/cloud-email-config.ts +39 -0
- package/infra/cloud-email.js +57 -0
- package/infra/cloud-email.test.js +69 -0
- package/infra/cloud-email.test.ts +66 -0
- package/infra/cloud-email.ts +60 -0
- package/infra/site-config.js +4 -2
- package/infra/site-config.ts +7 -2
- package/lib/news-desk-session.ts +12 -21
- package/lib/newsroom-organizer-nav.ts +2 -1
- package/package.json +2 -1
- package/routes.manifest.json +1 -1
|
@@ -166,7 +166,7 @@ export type PapyrusSiteBackendConfig = {
|
|
|
166
166
|
/**
|
|
167
167
|
* SES receipt options for a site that is not the legacy p.apyr.us backend.
|
|
168
168
|
* By default the site gets its own brand-named rule set that is never
|
|
169
|
-
* activated by CloudFormation (SES allows one active rule set per region).
|
|
169
|
+
* activated by CloudFormation (SES allows one active rule set per account/region).
|
|
170
170
|
*/
|
|
171
171
|
inboundEmailSes?: {
|
|
172
172
|
/** Add this site's rule to a rule set another stack owns instead of creating one. */
|
|
@@ -68,8 +68,10 @@ export function deriveStorageBackupVaultName(identity, explicitName) {
|
|
|
68
68
|
/**
|
|
69
69
|
* The `papyrus-site-inbound-` prefix keeps a brand named `p-apyr-us` from
|
|
70
70
|
* colliding with the legacy `papyrus-inbound-p-apyr-us` set.
|
|
71
|
-
* SES allows one ACTIVE receipt rule set per region. A non-legacy
|
|
72
|
-
* its own brand-named rule set and never activates it from
|
|
71
|
+
* SES allows one ACTIVE receipt rule set per account and region. A non-legacy
|
|
72
|
+
* site creates its own brand-named rule set and never activates it from
|
|
73
|
+
* CloudFormation. SES accounts that receive the same domain process their
|
|
74
|
+
* matching rules independently; the limit does not span accounts.
|
|
73
75
|
*/
|
|
74
76
|
export function deriveReceiptRuleSetName(identity, inboundEmailDomain) {
|
|
75
77
|
const name = isLegacyProductionApp(identity)
|
|
@@ -109,8 +109,10 @@ export function deriveStorageBackupVaultName(identity: SiteBackendIdentity, expl
|
|
|
109
109
|
/**
|
|
110
110
|
* The `papyrus-site-inbound-` prefix keeps a brand named `p-apyr-us` from
|
|
111
111
|
* colliding with the legacy `papyrus-inbound-p-apyr-us` set.
|
|
112
|
-
* SES allows one ACTIVE receipt rule set per region. A non-legacy
|
|
113
|
-
* its own brand-named rule set and never activates it from
|
|
112
|
+
* SES allows one ACTIVE receipt rule set per account and region. A non-legacy
|
|
113
|
+
* site creates its own brand-named rule set and never activates it from
|
|
114
|
+
* CloudFormation. SES accounts that receive the same domain process their
|
|
115
|
+
* matching rules independently; the limit does not span accounts.
|
|
114
116
|
*/
|
|
115
117
|
export function deriveReceiptRuleSetName(identity: SiteBackendIdentity, inboundEmailDomain: string): string {
|
|
116
118
|
const name = isLegacyProductionApp(identity)
|
package/amplify/site-backend.js
CHANGED
|
@@ -469,7 +469,7 @@ export function defineSiteBackend(papyrusSite) {
|
|
|
469
469
|
resources: ["*"],
|
|
470
470
|
}));
|
|
471
471
|
const inboundEventStack = Stack.of(receiveLambda);
|
|
472
|
-
// SES allows one active receipt rule set per region, so a non-legacy site
|
|
472
|
+
// SES allows one active receipt rule set per account and region, so a non-legacy site
|
|
473
473
|
// creates its own brand-named rule set and never activates it here.
|
|
474
474
|
const inboundSesPlan = planInboundEmailSes(siteBackendIdentity, enableInboundEmail, site.inboundEmailSes);
|
|
475
475
|
if (inboundSesPlan.createReceiptRules) {
|
package/amplify/site-backend.ts
CHANGED
|
@@ -601,7 +601,7 @@ export function defineSiteBackend(papyrusSite: PapyrusSite) {
|
|
|
601
601
|
|
|
602
602
|
const inboundEventStack = Stack.of(receiveLambda);
|
|
603
603
|
|
|
604
|
-
// SES allows one active receipt rule set per region, so a non-legacy site
|
|
604
|
+
// SES allows one active receipt rule set per account and region, so a non-legacy site
|
|
605
605
|
// creates its own brand-named rule set and never activates it here.
|
|
606
606
|
const inboundSesPlan = planInboundEmailSes(siteBackendIdentity, enableInboundEmail, site.inboundEmailSes);
|
|
607
607
|
if (inboundSesPlan.createReceiptRules) {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import { Hub } from "aws-amplify/utils";
|
|
4
|
+
import { isUnauthenticatedError } from "./reader-auth-state";
|
|
4
5
|
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
|
5
6
|
import type { DevSandboxEditorAuth } from "../lib/dev-sandbox-editor-auth";
|
|
6
7
|
import type { CategorySteeringDashboard } from "../lib/category-repository";
|
|
@@ -14,9 +15,10 @@ import {
|
|
|
14
15
|
} from "./news-desk-taxonomy-client";
|
|
15
16
|
import {
|
|
16
17
|
beginAccessCheck,
|
|
17
|
-
|
|
18
|
+
beginDeskLoad,
|
|
18
19
|
createInitialNewsDeskShellState,
|
|
19
20
|
patchDashboard,
|
|
21
|
+
resolveAccessFailure,
|
|
20
22
|
resolveDashboardFailure,
|
|
21
23
|
resolveDashboardReady,
|
|
22
24
|
resolveForbidden,
|
|
@@ -94,11 +96,11 @@ export function NewsDeskClientProvider({
|
|
|
94
96
|
return;
|
|
95
97
|
}
|
|
96
98
|
if (access.status === "error") {
|
|
97
|
-
setShell((current) =>
|
|
99
|
+
setShell((current) => resolveAccessFailure(current, access.error));
|
|
98
100
|
return;
|
|
99
101
|
}
|
|
100
102
|
|
|
101
|
-
setShell((current) =>
|
|
103
|
+
setShell((current) => beginDeskLoad(current, access.auth));
|
|
102
104
|
|
|
103
105
|
try {
|
|
104
106
|
const dashboard = await loadEditorNewsDeskDashboard({ isAdmin: access.isAdmin });
|
|
@@ -106,6 +108,10 @@ export function NewsDeskClientProvider({
|
|
|
106
108
|
setShell((current) => resolveDashboardReady(current, dashboard, access.auth, new Date().toISOString()));
|
|
107
109
|
} catch (error) {
|
|
108
110
|
if (bootstrapSequenceRef.current !== sequence) return;
|
|
111
|
+
if (isUnauthenticatedError(error)) {
|
|
112
|
+
setShell((current) => resolveSignedOut(current, { status: "signedOut", label: "Signed out" }));
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
109
115
|
if (isAuthorizationError(error)) {
|
|
110
116
|
setShell((current) => resolveForbidden(current, access.auth));
|
|
111
117
|
return;
|
|
@@ -132,11 +138,7 @@ export function NewsDeskClientProvider({
|
|
|
132
138
|
));
|
|
133
139
|
} catch (error) {
|
|
134
140
|
const auth = shellRef.current.auth;
|
|
135
|
-
setShell((current) =>
|
|
136
|
-
current,
|
|
137
|
-
auth,
|
|
138
|
-
error instanceof Error ? error.message : "Could not refresh assignments.",
|
|
139
|
-
));
|
|
141
|
+
setShell((current) => resolvePrivateReadFailure(current, auth, error, "Could not refresh assignments."));
|
|
140
142
|
}
|
|
141
143
|
}, []);
|
|
142
144
|
|
|
@@ -153,11 +155,7 @@ export function NewsDeskClientProvider({
|
|
|
153
155
|
));
|
|
154
156
|
} catch (error) {
|
|
155
157
|
const auth = shellRef.current.auth;
|
|
156
|
-
setShell((current) =>
|
|
157
|
-
current,
|
|
158
|
-
auth,
|
|
159
|
-
error instanceof Error ? error.message : "Could not refresh doctrine records.",
|
|
160
|
-
));
|
|
158
|
+
setShell((current) => resolvePrivateReadFailure(current, auth, error, "Could not refresh doctrine records."));
|
|
161
159
|
}
|
|
162
160
|
}, []);
|
|
163
161
|
|
|
@@ -175,11 +173,7 @@ export function NewsDeskClientProvider({
|
|
|
175
173
|
));
|
|
176
174
|
} catch (error) {
|
|
177
175
|
const auth = shellRef.current.auth;
|
|
178
|
-
setShell((current) =>
|
|
179
|
-
current,
|
|
180
|
-
auth,
|
|
181
|
-
error instanceof Error ? error.message : "Could not refresh the user directory.",
|
|
182
|
-
));
|
|
176
|
+
setShell((current) => resolvePrivateReadFailure(current, auth, error, "Could not refresh the user directory."));
|
|
183
177
|
}
|
|
184
178
|
}, []);
|
|
185
179
|
|
|
@@ -221,6 +215,12 @@ function isAuthorizationError(error: unknown): boolean {
|
|
|
221
215
|
return /not authorized|unauthorized|access denied|forbidden/i.test(message);
|
|
222
216
|
}
|
|
223
217
|
|
|
218
|
+
function resolvePrivateReadFailure(state: NewsDeskShellState, auth: NewsDeskShellState["auth"], error: unknown, fallback: string): NewsDeskShellState {
|
|
219
|
+
if (isUnauthenticatedError(error)) return resolveSignedOut(state, { status: "signedOut", label: "Signed out" });
|
|
220
|
+
if (isAuthorizationError(error)) return resolveForbidden(state, auth);
|
|
221
|
+
return resolveDashboardFailure(state, auth, error instanceof Error ? error.message : fallback);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
224
|
export function useOptionalNewsDeskClient() {
|
|
225
225
|
return useContext(NewsDeskClientContext);
|
|
226
226
|
}
|
|
@@ -832,10 +832,8 @@ export async function loadEditorResolvedAccessState(): Promise<EditorAccessState
|
|
|
832
832
|
};
|
|
833
833
|
}
|
|
834
834
|
try {
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
return { status: "signedOut", isEditor: false, isAdmin: false, auth: snapshot.auth, error: null };
|
|
838
|
-
}
|
|
835
|
+
// One refreshed session supplies both identity and current group claims.
|
|
836
|
+
// A second, sequential session read only extends the blocking access check.
|
|
839
837
|
const refreshed = await loadReaderSessionSnapshot({ forceRefresh: true });
|
|
840
838
|
if (!refreshed.hasSession || refreshed.auth.status === "signedOut") {
|
|
841
839
|
return { status: "signedOut", isEditor: false, isAdmin: false, auth: refreshed.auth, error: null };
|
|
@@ -883,9 +881,8 @@ export async function loadEditorNewsDeskDashboard({ isAdmin }: { isAdmin: boolea
|
|
|
883
881
|
isAdmin ? loadUserDirectory() : Promise.resolve([]),
|
|
884
882
|
]);
|
|
885
883
|
const summaryError = summaryResult.status === "rejected" ? summaryResult.reason : null;
|
|
886
|
-
if (summaryError
|
|
887
|
-
|
|
888
|
-
}
|
|
884
|
+
if (summaryError) throw summaryError;
|
|
885
|
+
if (userDirectoryResult.status === "rejected") throw userDirectoryResult.reason;
|
|
889
886
|
const summary = summaryResult.status === "fulfilled" ? summaryResult.value : null;
|
|
890
887
|
const userDirectory = userDirectoryResult.status === "fulfilled" ? userDirectoryResult.value : [];
|
|
891
888
|
|
|
@@ -6,11 +6,13 @@ import { SITE_BRAND } from "../lib/site-brand";
|
|
|
6
6
|
import type { NewsDeskShellState } from "../lib/news-desk-session";
|
|
7
7
|
import { cn } from "../lib/utils";
|
|
8
8
|
import { NewsroomOpsShell } from "./newsroom-ops-shell";
|
|
9
|
+
import { NewsroomOverviewSkeleton } from "./newsroom-overview-skeleton";
|
|
9
10
|
import { ReaderAuthControl } from "./reader-auth-control";
|
|
10
11
|
import { buttonVariants } from "./ui/button";
|
|
11
12
|
|
|
12
13
|
export function NewsDeskAccessGate({ shell, showSectionTabs = false }: { shell: NewsDeskShellState | null; showSectionTabs?: boolean }) {
|
|
13
14
|
const accessPhase = shell?.phase ?? "checkingAccess";
|
|
15
|
+
const pending = accessPhase === "checkingAccess" || accessPhase === "loadingDesk";
|
|
14
16
|
|
|
15
17
|
return (
|
|
16
18
|
<NewsroomOpsShell
|
|
@@ -21,9 +23,20 @@ export function NewsDeskAccessGate({ shell, showSectionTabs = false }: { shell:
|
|
|
21
23
|
headerActions={(
|
|
22
24
|
<Link className={cn(buttonVariants({ variant: "ghost", size: "sm" }))} href="/settings">Settings</Link>
|
|
23
25
|
)}
|
|
24
|
-
pageTitle="Newsroom"
|
|
26
|
+
pageTitle={showSectionTabs ? "Notification Center" : "Newsroom"}
|
|
25
27
|
showNavigation={showSectionTabs}
|
|
26
28
|
>
|
|
29
|
+
{pending ? (showSectionTabs ? <NewsroomOverviewSkeleton /> : (
|
|
30
|
+
<div aria-label="Loading newsroom" className="space-y-4" data-newsroom-page-skeleton role="status">
|
|
31
|
+
<div className="h-6 w-48 rounded bg-muted" />
|
|
32
|
+
<div className="h-4 w-3/4 rounded bg-muted" />
|
|
33
|
+
<div className="grid gap-3 sm:grid-cols-2">
|
|
34
|
+
{Array.from({ length: 4 }, (_, index) => <div className="h-32 rounded-xl border bg-card p-4" key={index}>
|
|
35
|
+
<div className="h-4 w-2/3 rounded bg-muted" /><div className="mt-4 h-3 w-1/2 rounded bg-muted" />
|
|
36
|
+
</div>)}
|
|
37
|
+
</div>
|
|
38
|
+
</div>
|
|
39
|
+
)) : (
|
|
27
40
|
<section aria-live="polite" data-news-desk-access={accessPhase} data-news-desk-access-phase={accessPhase}>
|
|
28
41
|
<div className="space-y-3 rounded-xl border border-border bg-card p-6">
|
|
29
42
|
<p className="m-0 text-[0.7rem] font-semibold uppercase tracking-[0.08em] text-muted-foreground">Access</p>
|
|
@@ -38,6 +51,7 @@ export function NewsDeskAccessGate({ shell, showSectionTabs = false }: { shell:
|
|
|
38
51
|
) : null}
|
|
39
52
|
</div>
|
|
40
53
|
</section>
|
|
54
|
+
)}
|
|
41
55
|
</NewsroomOpsShell>
|
|
42
56
|
);
|
|
43
57
|
}
|
|
@@ -513,8 +513,10 @@ export function NewsroomArticleEditor({ articleId, demo, embedded = false }: { a
|
|
|
513
513
|
}
|
|
514
514
|
if (!editor) {
|
|
515
515
|
return (
|
|
516
|
-
<div
|
|
517
|
-
<
|
|
516
|
+
<div aria-label="Loading article" className="space-y-5" role="status">
|
|
517
|
+
<div className="h-8 w-2/3 rounded bg-muted" />
|
|
518
|
+
<div className="grid gap-4 sm:grid-cols-2"><div className="h-12 rounded bg-muted" /><div className="h-12 rounded bg-muted" /></div>
|
|
519
|
+
<div className="h-64 rounded-xl border bg-card p-4"><div className="h-4 w-5/6 rounded bg-muted" /><div className="mt-3 h-4 w-3/4 rounded bg-muted" /></div>
|
|
518
520
|
</div>
|
|
519
521
|
);
|
|
520
522
|
}
|
|
@@ -5,7 +5,6 @@ import Link from "next/link";
|
|
|
5
5
|
import { useEffect, useMemo, useState } from "react";
|
|
6
6
|
import { Badge } from "./ui/badge";
|
|
7
7
|
import { buttonVariants } from "./ui/button";
|
|
8
|
-
import { Spinner } from "./ui/spinner";
|
|
9
8
|
import { Tabs, TabsList, TabsTrigger } from "./ui/tabs";
|
|
10
9
|
import { SITE_BRAND } from "../lib/site-brand";
|
|
11
10
|
import { getNewsroomNavHref } from "../lib/newsroom-nav";
|
|
@@ -89,15 +88,16 @@ export function NewsroomArticlesView({ demo }: { demo: boolean }) {
|
|
|
89
88
|
</TabsList>
|
|
90
89
|
</Tabs>
|
|
91
90
|
{loadError ? <NewsroomOpsStatusBanner tone="error">{loadError}</NewsroomOpsStatusBanner> : null}
|
|
92
|
-
{rows === null && !loadError ? (
|
|
93
|
-
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
94
|
-
<Spinner /> Loading articles…
|
|
95
|
-
</div>
|
|
96
|
-
) : null}
|
|
97
91
|
{rows !== null && visibleRows.length === 0 ? (
|
|
98
92
|
<p className="text-sm text-muted-foreground">Nothing here yet.</p>
|
|
99
93
|
) : null}
|
|
100
94
|
<ul className="divide-y divide-border rounded-xl border border-border bg-card" data-newsroom-articles-list>
|
|
95
|
+
{rows === null && !loadError ? Array.from({ length: 5 }, (_, index) => (
|
|
96
|
+
<li aria-hidden="true" className="flex items-center justify-between px-4 py-3" key={index}>
|
|
97
|
+
<span className="w-2/3"><span className="block h-4 w-3/4 rounded bg-muted" /><span className="mt-2 block h-3 w-1/2 rounded bg-muted" /></span>
|
|
98
|
+
<span className="h-5 w-16 rounded bg-muted" />
|
|
99
|
+
</li>
|
|
100
|
+
)) : null}
|
|
101
101
|
{visibleRows.map((row) => (
|
|
102
102
|
<li key={row.id}>
|
|
103
103
|
<Link
|
|
@@ -160,7 +160,7 @@ export function NewsroomBoardView({ subject, deskId }: { subject: NewsroomBoardS
|
|
|
160
160
|
if (!allowed) return <NewsDeskAccessGate shell={session?.shell ?? null} />;
|
|
161
161
|
return <NewsroomOpsShell activeTab={subject === "contentItem" ? "articles" : subject === "desk" ? "desks" : "assignments"} appTitle={SITE_BRAND.appTitle} backLabel={SITE_BRAND.backToHomeLabel} pageTitle={pageTitle}
|
|
162
162
|
headerActions={<>
|
|
163
|
-
<span role="status" className="organizer-board-note">{
|
|
163
|
+
{!loading ? <span role="status" className="organizer-board-note">{issues.length} records loaded.</span> : null}
|
|
164
164
|
<div role="group" aria-label="Record presentation" className="organizer-board-view-toggle"><button type="button" className="organizer-header-button" aria-pressed={view === "board"} onClick={() => setView("board")}>Board</button><button type="button" className="organizer-header-button" aria-pressed={view === "list"} onClick={() => setView("list")}>List</button></div>
|
|
165
165
|
{showsContentItems && (
|
|
166
166
|
<Link className="organizer-header-button" data-newsroom-article-new href={getNewsroomNavHref(newsroomHref("articles", "new"))}>
|
|
@@ -174,11 +174,16 @@ export function NewsroomBoardView({ subject, deskId }: { subject: NewsroomBoardS
|
|
|
174
174
|
{error && <NewsroomOpsStatusBanner tone="error">{error}</NewsroomOpsStatusBanner>}
|
|
175
175
|
{subject === "desk" && <IssueTypeFilter config={config} value={typeFilter} onChange={setTypeFilter} name="Record type" className="organizer-board-type-filter" />}
|
|
176
176
|
<div className="organizer-board-body" data-detail-open={Boolean(issue)} ref={boardBodyRef} onScrollCapture={handleBoardScroll}>
|
|
177
|
-
|
|
177
|
+
{loading && !issues.length ? <div aria-label="Loading board records" className="grid w-full grid-cols-1 gap-3 sm:grid-cols-3" role="status">
|
|
178
|
+
{Array.from({ length: 3 }, (_, column) => <div className="min-h-64 space-y-3 rounded-xl border bg-card p-4" key={column}>
|
|
179
|
+
<div className="h-4 w-1/2 rounded bg-muted" />
|
|
180
|
+
{Array.from({ length: 2 }, (_, row) => <div className="h-24 rounded-lg border bg-background p-3" key={row}><div className="h-4 w-3/4 rounded bg-muted" /><div className="mt-3 h-3 w-1/2 rounded bg-muted" /></div>)}
|
|
181
|
+
</div>)}
|
|
182
|
+
</div> : <IssueCollection issues={issues} config={config} query={{ type: subject === "desk" ? issueTypeFromFilter(typeFilter) : boardType }} view={view}
|
|
178
183
|
motion={lowMotion ? STATIC_MOTION : undefined}
|
|
179
184
|
collapsedColumns={columnState.collapsedColumns} onToggleCollapse={columnState.toggleColumn} initialScroll="start"
|
|
180
185
|
selectedIssueId={selected} detailOpen={Boolean(issue)} onSelectIssue={item => setSelected(item.id)} ariaLabel={pageTitle}
|
|
181
|
-
emptyMessage={
|
|
186
|
+
emptyMessage={error ? "Records could not be loaded." : "No records in this selection."} />}
|
|
182
187
|
{issue && <IssueDetailPanel issue={issue} onClose={() => setSelected(null)} fields={[]}>
|
|
183
188
|
{issue.kind === "contentItem"
|
|
184
189
|
? <>
|
|
@@ -1,24 +1,13 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import {
|
|
4
|
-
BookOpenIcon,
|
|
5
|
-
ClipboardListIcon,
|
|
6
|
-
FileTextIcon,
|
|
7
|
-
LayersIcon,
|
|
8
|
-
MessageSquareIcon,
|
|
9
|
-
NetworkIcon,
|
|
10
|
-
SearchIcon,
|
|
11
|
-
SettingsIcon,
|
|
12
|
-
} from "lucide-react";
|
|
13
3
|
import Link from "next/link";
|
|
14
4
|
import { usePathname } from "next/navigation";
|
|
15
5
|
import { useMemo } from "react";
|
|
16
6
|
import { Badge } from "./ui/badge";
|
|
17
7
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card";
|
|
18
|
-
import type { AssignmentRecord,
|
|
19
|
-
import { getNewsroomNavHref
|
|
8
|
+
import type { AssignmentRecord, NewsroomSectionRecord, ReferenceRecord } from "../lib/category-repository";
|
|
9
|
+
import { getNewsroomNavHref } from "../lib/newsroom-nav";
|
|
20
10
|
import {
|
|
21
|
-
countReferencesByStatus,
|
|
22
11
|
filterReferencesByStatus,
|
|
23
12
|
formatReferenceListDate,
|
|
24
13
|
referenceDisplayTitle,
|
|
@@ -42,7 +31,6 @@ type NewsroomOpsOverviewProps = {
|
|
|
42
31
|
decisions?: ReadonlyMap<string, RelevanceDecisionView>;
|
|
43
32
|
demo?: boolean;
|
|
44
33
|
humanCuratedReferenceLineageIds?: ReadonlySet<string>;
|
|
45
|
-
messages: MessageRecord[];
|
|
46
34
|
newsroomSections: NewsroomSectionRecord[];
|
|
47
35
|
references: ReferenceRecord[];
|
|
48
36
|
};
|
|
@@ -50,51 +38,17 @@ type NewsroomOpsOverviewProps = {
|
|
|
50
38
|
const NO_DECISIONS: ReadonlyMap<string, RelevanceDecisionView> = new Map();
|
|
51
39
|
const NO_IDS: ReadonlySet<string> = new Set();
|
|
52
40
|
|
|
53
|
-
type DestinationKey = Exclude<NewsroomNavTabId, "overview" | "desks">;
|
|
54
|
-
|
|
55
|
-
const DESTINATION_ICONS: Record<DestinationKey, typeof BookOpenIcon> = {
|
|
56
|
-
articles: FileTextIcon,
|
|
57
|
-
assignments: ClipboardListIcon,
|
|
58
|
-
references: BookOpenIcon,
|
|
59
|
-
messages: MessageSquareIcon,
|
|
60
|
-
topics: LayersIcon,
|
|
61
|
-
concepts: NetworkIcon,
|
|
62
|
-
administration: SettingsIcon,
|
|
63
|
-
search: SearchIcon,
|
|
64
|
-
};
|
|
65
|
-
|
|
66
|
-
const DESTINATIONS = NEWSROOM_OPS_NAV.filter((item) => item.id !== "overview").map((item) => ({
|
|
67
|
-
key: item.id as DestinationKey,
|
|
68
|
-
label: item.label,
|
|
69
|
-
detail: item.detail,
|
|
70
|
-
href: item.href,
|
|
71
|
-
icon: DESTINATION_ICONS[item.id as DestinationKey],
|
|
72
|
-
}));
|
|
73
|
-
|
|
74
|
-
function openAssignmentCount(assignments: AssignmentRecord[]): number {
|
|
75
|
-
return assignments.filter((assignment) => {
|
|
76
|
-
const status = assignment.status?.trim().toLowerCase() ?? "";
|
|
77
|
-
return status === "open" || status === "claimed" || status === "pending";
|
|
78
|
-
}).length;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
function recentMessageCount(messages: MessageRecord[]): number {
|
|
82
|
-
return messages.filter((message) => message.status !== "archived").length;
|
|
83
|
-
}
|
|
84
|
-
|
|
85
41
|
export function NewsroomOpsOverview({
|
|
86
42
|
actorLabel = "",
|
|
87
43
|
assignments,
|
|
88
44
|
decisions = NO_DECISIONS,
|
|
89
45
|
demo = false,
|
|
90
46
|
humanCuratedReferenceLineageIds = NO_IDS,
|
|
91
|
-
messages,
|
|
92
47
|
newsroomSections,
|
|
93
48
|
references,
|
|
94
49
|
}: NewsroomOpsOverviewProps) {
|
|
95
50
|
const pathname = usePathname();
|
|
96
51
|
const canonicalReferences = useMemo(() => selectCanonicalReferenceRecords(references), [references]);
|
|
97
|
-
const referenceCounts = useMemo(() => countReferencesByStatus(canonicalReferences), [canonicalReferences]);
|
|
98
52
|
const pendingReferences = useMemo(
|
|
99
53
|
() => filterReferencesByStatus(canonicalReferences, "pending").slice(0, 5),
|
|
100
54
|
[canonicalReferences],
|
|
@@ -115,12 +69,6 @@ export function NewsroomOpsOverview({
|
|
|
115
69
|
const coreDesks = useMemo(() => desks.filter(desk => newsroomDeskKind(desk) === "core"), [desks]);
|
|
116
70
|
const rotatingDesks = useMemo(() => desks.filter(desk => newsroomDeskKind(desk) === "rotating"), [desks]);
|
|
117
71
|
|
|
118
|
-
const destinationCounts: Partial<Record<DestinationKey, number | null>> = {
|
|
119
|
-
references: referenceCounts.pending ?? 0,
|
|
120
|
-
assignments: openAssignmentCount(assignments),
|
|
121
|
-
messages: recentMessageCount(messages),
|
|
122
|
-
};
|
|
123
|
-
|
|
124
72
|
return (
|
|
125
73
|
<div
|
|
126
74
|
className="space-y-8 font-sans"
|
|
@@ -128,51 +76,15 @@ export function NewsroomOpsOverview({
|
|
|
128
76
|
data-newsroom-ops-home="true"
|
|
129
77
|
>
|
|
130
78
|
<div className="space-y-8" data-newsroom-overview-feeds>
|
|
131
|
-
<
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
|
141
|
-
{DESTINATIONS.map((meta) => {
|
|
142
|
-
const key = meta.key;
|
|
143
|
-
const Icon = meta.icon;
|
|
144
|
-
const count = destinationCounts[key];
|
|
145
|
-
const href = getNewsroomNavHref(meta.href, demo);
|
|
146
|
-
return (
|
|
147
|
-
<Link
|
|
148
|
-
className="group block rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
149
|
-
data-newsroom-ops-destination={key}
|
|
150
|
-
data-newsroom-overview-section={key}
|
|
151
|
-
href={href}
|
|
152
|
-
key={key}
|
|
153
|
-
>
|
|
154
|
-
<Card className="h-full transition-colors group-hover:border-primary/40">
|
|
155
|
-
<CardHeader className="space-y-3 p-4">
|
|
156
|
-
<div className="flex items-center justify-between gap-2">
|
|
157
|
-
<span className="flex size-9 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
|
158
|
-
<Icon className="size-4" aria-hidden="true" />
|
|
159
|
-
</span>
|
|
160
|
-
{count != null && count > 0 ? (
|
|
161
|
-
<Badge variant="default">{count}</Badge>
|
|
162
|
-
) : null}
|
|
163
|
-
</div>
|
|
164
|
-
<div className="space-y-0.5">
|
|
165
|
-
<CardTitle className="text-base font-semibold">{meta.label}</CardTitle>
|
|
166
|
-
<CardDescription>{meta.detail}</CardDescription>
|
|
167
|
-
</div>
|
|
168
|
-
</CardHeader>
|
|
169
|
-
</Card>
|
|
170
|
-
</Link>
|
|
171
|
-
);
|
|
172
|
-
})}
|
|
173
|
-
</div>
|
|
174
|
-
</section>
|
|
175
|
-
|
|
79
|
+
<div aria-label="Notification Center feed" className="newsroom-overview-feed-frame overflow-y-auto rounded-xl border bg-card p-4 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" data-newsroom-overview-notifications role="region" tabIndex={0}>
|
|
80
|
+
<NotificationCenter
|
|
81
|
+
actorLabel={actorLabel}
|
|
82
|
+
decisions={decisions}
|
|
83
|
+
demo={demo}
|
|
84
|
+
humanCuratedReferenceLineageIds={humanCuratedReferenceLineageIds}
|
|
85
|
+
references={canonicalReferences}
|
|
86
|
+
/>
|
|
87
|
+
</div>
|
|
176
88
|
<section aria-label="Needs attention" className="space-y-3">
|
|
177
89
|
<h2 className="text-sm font-medium text-muted-foreground">Needs attention</h2>
|
|
178
90
|
{pendingReferences.length || attentionAssignments.length ? (
|
|
@@ -226,7 +138,7 @@ export function NewsroomOpsOverview({
|
|
|
226
138
|
) : (
|
|
227
139
|
<Card>
|
|
228
140
|
<CardContent className="p-4 text-sm text-muted-foreground">
|
|
229
|
-
|
|
141
|
+
No pending references or open assignments. Check those destinations for other work.
|
|
230
142
|
</CardContent>
|
|
231
143
|
</Card>
|
|
232
144
|
)}
|
|
@@ -8,7 +8,7 @@ import { cn } from "../lib/utils";
|
|
|
8
8
|
import { useEffect, useId, useState, type ReactNode } from "react";
|
|
9
9
|
import Link from "next/link";
|
|
10
10
|
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
|
11
|
-
import { BookOpen, ClipboardList, Menu, Search, MessagesSquare, PanelLeftClose, PanelLeftOpen, Settings, UserRound, LogIn, LogOut } from "lucide-react";
|
|
11
|
+
import { BookOpen, ClipboardList, House, Menu, Search, MessagesSquare, PanelLeftClose, PanelLeftOpen, Settings, UserRound, LogIn, LogOut } from "lucide-react";
|
|
12
12
|
import { ORGANIZER_NAV_GROUPS, ORGANIZER_PRIMARY_NAV, ORGANIZER_SETTINGS_DESTINATION } from "../lib/newsroom-organizer-nav";
|
|
13
13
|
import { browserPathToInternalNewsroomPath } from "../lib/newsroom-base-path";
|
|
14
14
|
import { getNewsroomNavHref } from "../lib/newsroom-nav";
|
|
@@ -55,7 +55,7 @@ function OrganizerSearch({ demo }: { demo: boolean }) {
|
|
|
55
55
|
</form>;
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
-
const ICONS = { articles: ClipboardList, references: BookOpen, messages: MessagesSquare };
|
|
58
|
+
const ICONS = { overview: House, articles: ClipboardList, references: BookOpen, messages: MessagesSquare };
|
|
59
59
|
|
|
60
60
|
/** Navigation is a presentation layer; existing route and server authorization still own access. */
|
|
61
61
|
function Navigation({ activePath, demo, admin, surface, onNavigate }: { activePath: string; demo: boolean; admin: boolean; surface: string; onNavigate?: () => void }) {
|
|
@@ -23,6 +23,8 @@ body:has(.newsroom-organizer)>[data-staging-banner]{flex-shrink:0}
|
|
|
23
23
|
.organizer-sidebar-head{display:flex;align-items:flex-start;justify-content:space-between;gap:8px}.organizer-sidebar-head .organizer-brand{flex:1;min-width:0}
|
|
24
24
|
.organizer-header-actions{display:flex;align-items:center;gap:8px;min-width:0;max-width:45%;overflow-x:auto}
|
|
25
25
|
.organizer-main{min-width:0;min-height:0;overflow:auto;overscroll-behavior:contain;flex:1;padding:16px 0}
|
|
26
|
+
.newsroom-overview-feed-frame{height:32rem}
|
|
27
|
+
@media(min-width:640px){.newsroom-overview-feed-frame{height:38rem}}
|
|
26
28
|
.organizer-main>[data-category-steering]{min-height:0}.organizer-demo-notice{font-size:11px;color:var(--org-muted);margin-bottom:12px}
|
|
27
29
|
.organizer-account{display:flex;align-items:center;gap:10px;min-height:44px;flex-shrink:0;border:1px solid var(--org-line);padding:8px;text-align:left;border-radius:3px}
|
|
28
30
|
.organizer-account span{min-width:0}.organizer-account strong{display:block;font:600 11px var(--kb-theme-ui-font);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.organizer-account small{display:block;font-size:9px;color:var(--org-muted);margin-top:2px}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
|
|
5
|
+
/** Placeholder geometry for the overview while private records or access are unresolved. */
|
|
6
|
+
export function NewsroomOverviewSkeleton() {
|
|
7
|
+
return (
|
|
8
|
+
<div aria-label="Loading newsroom overview" className="space-y-8 font-sans" data-newsroom-overview-skeleton role="status">
|
|
9
|
+
<section className="newsroom-overview-feed-frame space-y-4 overflow-hidden rounded-xl border bg-card p-4" aria-label="Notification Center">
|
|
10
|
+
<div className="h-6 w-48 rounded bg-muted" />
|
|
11
|
+
<div className="h-4 w-3/4 max-w-xl rounded bg-muted" />
|
|
12
|
+
<div className="h-4 w-2/3 max-w-lg rounded bg-muted" />
|
|
13
|
+
<div className="grid gap-3 sm:grid-cols-2">
|
|
14
|
+
{Array.from({ length: 4 }, (_, index) => <div className="h-28 rounded-xl border bg-card p-4" key={index}>
|
|
15
|
+
<div className="h-4 w-2/3 rounded bg-muted" /><div className="mt-4 h-3 w-1/2 rounded bg-muted" />
|
|
16
|
+
</div>)}
|
|
17
|
+
</div>
|
|
18
|
+
</section>
|
|
19
|
+
<section aria-label="Needs attention" className="space-y-3">
|
|
20
|
+
<div className="h-4 w-32 rounded bg-muted" />
|
|
21
|
+
<div className="h-16 rounded-xl border bg-card p-4"><div className="h-4 w-1/2 rounded bg-muted" /><div className="mt-2 h-3 w-1/3 rounded bg-muted" /></div>
|
|
22
|
+
</section>
|
|
23
|
+
</div>
|
|
24
|
+
);
|
|
25
|
+
}
|
|
@@ -131,9 +131,20 @@ export function NotificationCenterView({ loadState, requests, readIds, held, aut
|
|
|
131
131
|
const entry = (request: ReviewRequest) => (
|
|
132
132
|
<Entry busy={busyId === request.id} demo={demo} key={request.id} onAnswer={onAnswer} onMarkRead={onMarkRead} request={request} unread={isUnread(request, readIds)} />
|
|
133
133
|
);
|
|
134
|
-
if (loadState === "loading") return
|
|
134
|
+
if (loadState === "loading") return (
|
|
135
|
+
<section aria-label="Loading Notification Center" className="space-y-4" data-notification-state="loading" role="status">
|
|
136
|
+
<div className="h-6 w-48 rounded bg-muted" />
|
|
137
|
+
<div className="h-4 w-3/4 max-w-xl rounded bg-muted" />
|
|
138
|
+
<div className="h-4 w-2/3 max-w-lg rounded bg-muted" />
|
|
139
|
+
<div className="grid gap-3 sm:grid-cols-2">
|
|
140
|
+
{Array.from({ length: 4 }, (_, index) => <div className="h-28 rounded-xl border bg-card p-4" key={index}>
|
|
141
|
+
<div className="h-4 w-2/3 rounded bg-muted" /><div className="mt-4 h-3 w-1/2 rounded bg-muted" />
|
|
142
|
+
</div>)}
|
|
143
|
+
</div>
|
|
144
|
+
</section>
|
|
145
|
+
);
|
|
135
146
|
if (loadState === "forbidden") return <div role="alert" data-notification-state="forbidden"><strong>The Notification Center is for editors and admins</strong></div>;
|
|
136
|
-
if (loadState === "unavailable") return <div role="alert" data-notification-state="unavailable"><strong>Review requests could not be read right now</strong><p>Try again in a minute.
|
|
147
|
+
if (loadState === "unavailable") return <div role="alert" data-notification-state="unavailable"><strong>Review requests could not be read right now</strong><p>Try again in a minute. The current request state is unknown.</p></div>;
|
|
137
148
|
return (
|
|
138
149
|
<div className="space-y-6" data-notification-center>
|
|
139
150
|
<header className="space-y-1">
|
|
@@ -61,7 +61,7 @@ export async function loadReaderSessionSnapshot(options?: { forceRefresh?: boole
|
|
|
61
61
|
|
|
62
62
|
export function isUnauthenticatedError(error: unknown): boolean {
|
|
63
63
|
const message = error instanceof Error ? error.message : String(error);
|
|
64
|
-
return /unauth|not authenticated|no current user|not signed in/i.test(message);
|
|
64
|
+
return /unauth|not.?authorized|not authenticated|no current user|not signed in|session expired|token expired|invalid.*token|\b401\b/i.test(message);
|
|
65
65
|
}
|
|
66
66
|
|
|
67
67
|
function readIdentityLabel(claims: {
|
|
@@ -674,7 +674,7 @@ export function NewsDeskWorkspace({
|
|
|
674
674
|
);
|
|
675
675
|
}
|
|
676
676
|
|
|
677
|
-
if (session && sessionDashboard) {
|
|
677
|
+
if (session && sessionDashboard && session.shell.phase !== "loadingDesk") {
|
|
678
678
|
return (
|
|
679
679
|
<NewsDeskDashboard
|
|
680
680
|
analysisProfiles={analysisProfiles}
|
|
@@ -3081,7 +3081,6 @@ function NewsDeskDashboard({
|
|
|
3081
3081
|
decisions={relevanceDecisions}
|
|
3082
3082
|
demo={Boolean(dashboard.isDemo)}
|
|
3083
3083
|
humanCuratedReferenceLineageIds={humanCuratedReferenceLineageIds}
|
|
3084
|
-
messages={messages}
|
|
3085
3084
|
newsroomSections={newsroomSections}
|
|
3086
3085
|
references={references}
|
|
3087
3086
|
/>
|
|
@@ -2,6 +2,8 @@ import { ArnFormat, CfnOutput, Duration, Fn, Stack } from "aws-cdk-lib";
|
|
|
2
2
|
import * as iam from "aws-cdk-lib/aws-iam";
|
|
3
3
|
import * as amplify from "aws-cdk-lib/aws-amplify";
|
|
4
4
|
import { SecureStringParameter } from "./secure-string-parameter.js";
|
|
5
|
+
import { addCloudEmail } from "./cloud-email.js";
|
|
6
|
+
import { parseCloudEmail } from "./cloud-email-config.js";
|
|
5
7
|
import { GITHUB_OIDC_PROVIDER_HOST } from "./github-oidc-provider.js";
|
|
6
8
|
import { cmsProductionBuildSpec, cmsStagingBuildSpec, readerBuildSpec } from "./build-specs.js";
|
|
7
9
|
import { PROSPECTUS_PAPYRUS_SITE_ROUTES, hasCmsApp, isStagingEnabled, resolveRedirectSubDomainPrefix, resolveCmsHostName, resolveSiteOrigin, githubOidcSubjects, resolveGithubRepository, resolveProspectusAccountsEndpoint, resolveProspectusSessionSealingParameterName, resolveReaderPollSecretParameterName, resolveRevalidateSecretParameterName, resolveStackName, resolveStagingDomainName, resolveStoragePreviewPrefix } from "./site-config.js";
|
|
@@ -74,6 +76,7 @@ export class AmplifyAppShellStack extends Stack {
|
|
|
74
76
|
return;
|
|
75
77
|
}
|
|
76
78
|
const cmsAppName = config.cms.appName ?? `${config.siteId}-cms`;
|
|
79
|
+
const emailEnvironment = addCloudEmail(this, parseCloudEmail(config.email, true, config.siteId));
|
|
77
80
|
const stagingEnabled = isStagingEnabled(config);
|
|
78
81
|
const stagingDomainName = resolveStagingDomainName(config);
|
|
79
82
|
const cmsHostName = resolveCmsHostName(config);
|
|
@@ -195,6 +198,7 @@ export class AmplifyAppShellStack extends Stack {
|
|
|
195
198
|
enablePerformanceMode: false,
|
|
196
199
|
environmentVariables: environmentVariables({
|
|
197
200
|
...config.cms.environment,
|
|
201
|
+
...emailEnvironment,
|
|
198
202
|
PAPYRUS_COGNITO_DOMAIN_PREFIX: config.cms.cognitoDomainPrefix,
|
|
199
203
|
...(config.cms.applyCognitoDomainPrefix ? { PAPYRUS_APPLY_COGNITO_DOMAIN_PREFIX: "true" } : {}),
|
|
200
204
|
PAPYRUS_OAUTH_REDIRECT_URLS: oauthRedirectUrls,
|
|
@@ -3,6 +3,8 @@ import { ArnFormat, CfnOutput, Duration, Fn, Stack, StackProps } from "aws-cdk-l
|
|
|
3
3
|
import * as iam from "aws-cdk-lib/aws-iam";
|
|
4
4
|
import * as amplify from "aws-cdk-lib/aws-amplify";
|
|
5
5
|
import { SecureStringParameter } from "./secure-string-parameter";
|
|
6
|
+
import { addCloudEmail } from "./cloud-email";
|
|
7
|
+
import { parseCloudEmail } from "./cloud-email-config";
|
|
6
8
|
import { GITHUB_OIDC_PROVIDER_HOST } from "./github-oidc-provider";
|
|
7
9
|
import { cmsProductionBuildSpec, cmsStagingBuildSpec, readerBuildSpec } from "./build-specs";
|
|
8
10
|
import { AmplifyAppShellSiteConfig, PROSPECTUS_PAPYRUS_SITE_ROUTES, ReaderCustomHeaderRule, hasCmsApp, isStagingEnabled, resolveRedirectSubDomainPrefix, resolveCmsHostName, resolveSiteOrigin, githubOidcSubjects, resolveGithubRepository, resolveProspectusAccountsEndpoint, resolveProspectusSessionSealingParameterName, resolveReaderPollSecretParameterName, resolveRevalidateSecretParameterName, resolveStackName, resolveStagingDomainName, resolveStoragePreviewPrefix } from "./site-config";
|
|
@@ -79,6 +81,7 @@ export class AmplifyAppShellStack extends Stack {
|
|
|
79
81
|
}
|
|
80
82
|
|
|
81
83
|
const cmsAppName = config.cms.appName ?? `${config.siteId}-cms`;
|
|
84
|
+
const emailEnvironment = addCloudEmail(this, parseCloudEmail(config.email, true, config.siteId)!);
|
|
82
85
|
const stagingEnabled = isStagingEnabled(config);
|
|
83
86
|
const stagingDomainName = resolveStagingDomainName(config);
|
|
84
87
|
const cmsHostName = resolveCmsHostName(config);
|
|
@@ -217,6 +220,7 @@ export class AmplifyAppShellStack extends Stack {
|
|
|
217
220
|
enablePerformanceMode: false,
|
|
218
221
|
environmentVariables: environmentVariables({
|
|
219
222
|
...config.cms.environment,
|
|
223
|
+
...emailEnvironment,
|
|
220
224
|
PAPYRUS_COGNITO_DOMAIN_PREFIX: config.cms.cognitoDomainPrefix,
|
|
221
225
|
...(config.cms.applyCognitoDomainPrefix ? { PAPYRUS_APPLY_COGNITO_DOMAIN_PREFIX: "true" } : {}),
|
|
222
226
|
PAPYRUS_OAUTH_REDIRECT_URLS: oauthRedirectUrls,
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export function parseCloudEmail(raw, cloud, siteId) {
|
|
2
|
+
if (!cloud) {
|
|
3
|
+
if (raw !== undefined)
|
|
4
|
+
throw new Error("site.json: email requires a cloud cms; static-only sites do not need email resources");
|
|
5
|
+
return undefined;
|
|
6
|
+
}
|
|
7
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
8
|
+
throw new Error("site.json: email is required for cloud sites; configure mode managed or existing and an explicit sendingDomain (see docs/email-reference-intake.md)");
|
|
9
|
+
}
|
|
10
|
+
const value = raw;
|
|
11
|
+
for (const key of Object.keys(value)) {
|
|
12
|
+
if (!["mode", "sendingDomain", "senderLocalPart", "configurationSet", "eventTopicName"].includes(key)) {
|
|
13
|
+
throw new Error(`site.json: email.${key} is not a known field`);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
if (value.mode !== "managed" && value.mode !== "existing")
|
|
17
|
+
throw new Error("site.json: email.mode must be managed or existing");
|
|
18
|
+
const string = (key, pattern, fallback) => {
|
|
19
|
+
const result = value[key] ?? fallback;
|
|
20
|
+
if (typeof result !== "string" || !pattern.test(result))
|
|
21
|
+
throw new Error(`site.json: email.${key} is required and must be valid`);
|
|
22
|
+
return result;
|
|
23
|
+
};
|
|
24
|
+
return {
|
|
25
|
+
mode: value.mode,
|
|
26
|
+
sendingDomain: string("sendingDomain", /^(?=.{1,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/),
|
|
27
|
+
senderLocalPart: string("senderLocalPart", /^[a-z0-9][a-z0-9._+-]{0,63}$/, "notifications"),
|
|
28
|
+
configurationSet: string("configurationSet", /^[A-Za-z0-9_-]{1,64}$/, value.mode === "managed" ? `papyrus-${siteId}` : undefined),
|
|
29
|
+
// Preserve the newsletter consumer's historical default; the topic now also serves notifications.
|
|
30
|
+
eventTopicName: string("eventTopicName", /^[A-Za-z0-9_-]{1,256}$/, value.mode === "managed" ? `papyrus-newsletter-events-${siteId}` : undefined),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/** Email belongs to a cloud site, independently of any newsletter plugin. */
|
|
2
|
+
export type CloudEmailConfig = {
|
|
3
|
+
mode: "managed" | "existing";
|
|
4
|
+
sendingDomain: string;
|
|
5
|
+
senderLocalPart: string;
|
|
6
|
+
configurationSet: string;
|
|
7
|
+
eventTopicName: string;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export function parseCloudEmail(raw: unknown, cloud: boolean, siteId: string): CloudEmailConfig | undefined {
|
|
11
|
+
if (!cloud) {
|
|
12
|
+
if (raw !== undefined) throw new Error("site.json: email requires a cloud cms; static-only sites do not need email resources");
|
|
13
|
+
return undefined;
|
|
14
|
+
}
|
|
15
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
16
|
+
throw new Error("site.json: email is required for cloud sites; configure mode managed or existing and an explicit sendingDomain (see docs/email-reference-intake.md)");
|
|
17
|
+
}
|
|
18
|
+
const value = raw as Record<string, unknown>;
|
|
19
|
+
for (const key of Object.keys(value)) {
|
|
20
|
+
if (!["mode", "sendingDomain", "senderLocalPart", "configurationSet", "eventTopicName"].includes(key)) {
|
|
21
|
+
throw new Error(`site.json: email.${key} is not a known field`);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
if (value.mode !== "managed" && value.mode !== "existing") throw new Error("site.json: email.mode must be managed or existing");
|
|
25
|
+
const string = (key: string, pattern: RegExp, fallback?: string) => {
|
|
26
|
+
const result = value[key] ?? fallback;
|
|
27
|
+
if (typeof result !== "string" || !pattern.test(result)) throw new Error(`site.json: email.${key} is required and must be valid`);
|
|
28
|
+
return result;
|
|
29
|
+
};
|
|
30
|
+
return {
|
|
31
|
+
mode: value.mode,
|
|
32
|
+
sendingDomain: string("sendingDomain", /^(?=.{1,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/),
|
|
33
|
+
senderLocalPart: string("senderLocalPart", /^[a-z0-9][a-z0-9._+-]{0,63}$/, "notifications"),
|
|
34
|
+
configurationSet: string("configurationSet", /^[A-Za-z0-9_-]{1,64}$/, value.mode === "managed" ? `papyrus-${siteId}` : undefined),
|
|
35
|
+
// Preserve the newsletter consumer's historical default; the topic now also serves notifications.
|
|
36
|
+
eventTopicName: string("eventTopicName", /^[A-Za-z0-9_-]{1,256}$/, value.mode === "managed" ? `papyrus-newsletter-events-${siteId}` : undefined),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { ArnFormat, CfnOutput, RemovalPolicy } from "aws-cdk-lib";
|
|
2
|
+
import * as iam from "aws-cdk-lib/aws-iam";
|
|
3
|
+
import * as ses from "aws-cdk-lib/aws-ses";
|
|
4
|
+
import * as sns from "aws-cdk-lib/aws-sns";
|
|
5
|
+
/** No DNS mutation, sending grant or receipt activation. Existing identities remain with their owner. */
|
|
6
|
+
export function addCloudEmail(stack, config) {
|
|
7
|
+
if (config.mode === "managed") {
|
|
8
|
+
const configurationSet = new ses.CfnConfigurationSet(stack, "CloudEmailConfigurationSet", {
|
|
9
|
+
name: config.configurationSet,
|
|
10
|
+
reputationOptions: { reputationMetricsEnabled: true },
|
|
11
|
+
suppressionOptions: { suppressedReasons: ["BOUNCE", "COMPLAINT"] },
|
|
12
|
+
sendingOptions: { sendingEnabled: true },
|
|
13
|
+
});
|
|
14
|
+
configurationSet.applyRemovalPolicy(RemovalPolicy.RETAIN);
|
|
15
|
+
const identity = new ses.CfnEmailIdentity(stack, "CloudEmailIdentity", {
|
|
16
|
+
emailIdentity: config.sendingDomain,
|
|
17
|
+
dkimSigningAttributes: { nextSigningKeyLength: "RSA_2048_BIT" },
|
|
18
|
+
configurationSetAttributes: { configurationSetName: configurationSet.ref },
|
|
19
|
+
});
|
|
20
|
+
identity.applyRemovalPolicy(RemovalPolicy.RETAIN);
|
|
21
|
+
const topic = new sns.Topic(stack, "CloudEmailEvents", { topicName: config.eventTopicName });
|
|
22
|
+
topic.applyRemovalPolicy(RemovalPolicy.RETAIN);
|
|
23
|
+
topic.addToResourcePolicy(new iam.PolicyStatement({
|
|
24
|
+
principals: [new iam.ServicePrincipal("ses.amazonaws.com")], actions: ["sns:Publish"],
|
|
25
|
+
resources: [topic.topicArn], conditions: { StringEquals: { "aws:SourceAccount": stack.account } },
|
|
26
|
+
}));
|
|
27
|
+
const events = new ses.CfnConfigurationSetEventDestination(stack, "CloudEmailEventDestination", {
|
|
28
|
+
configurationSetName: configurationSet.ref,
|
|
29
|
+
eventDestination: { name: "delivery-events", enabled: true,
|
|
30
|
+
matchingEventTypes: ["SEND", "DELIVERY", "BOUNCE", "COMPLAINT", "REJECT", "RENDERING_FAILURE", "DELIVERY_DELAY"],
|
|
31
|
+
snsDestination: { topicArn: topic.topicArn } },
|
|
32
|
+
});
|
|
33
|
+
events.node.addDependency(topic);
|
|
34
|
+
// These dimensions also feed the newsletter consumer's existing bounce/complaint alarms.
|
|
35
|
+
new ses.CfnConfigurationSetEventDestination(stack, "CloudEmailMetrics", {
|
|
36
|
+
configurationSetName: configurationSet.ref,
|
|
37
|
+
eventDestination: { name: "cloudwatch-metrics", enabled: true,
|
|
38
|
+
matchingEventTypes: ["SEND", "BOUNCE", "COMPLAINT"],
|
|
39
|
+
cloudWatchDestination: { dimensionConfigurations: [{ dimensionName: "ses:configuration-set",
|
|
40
|
+
dimensionValueSource: "MESSAGE_TAG", defaultDimensionValue: config.configurationSet }] } },
|
|
41
|
+
});
|
|
42
|
+
const records = [[identity.attrDkimDnsTokenName1, identity.attrDkimDnsTokenValue1],
|
|
43
|
+
[identity.attrDkimDnsTokenName2, identity.attrDkimDnsTokenValue2],
|
|
44
|
+
[identity.attrDkimDnsTokenName3, identity.attrDkimDnsTokenValue3]];
|
|
45
|
+
records.forEach(([name, value], index) => {
|
|
46
|
+
new CfnOutput(stack, `EmailDkimRecord${index + 1}Name`, { value: name, description: "CNAME name; DNS change requires separate approval" });
|
|
47
|
+
new CfnOutput(stack, `EmailDkimRecord${index + 1}Value`, { value });
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
const identityArn = stack.formatArn({ service: "ses", resource: "identity", resourceName: config.sendingDomain, arnFormat: ArnFormat.SLASH_RESOURCE_NAME });
|
|
51
|
+
new CfnOutput(stack, "EmailIdentityArn", { value: identityArn });
|
|
52
|
+
new CfnOutput(stack, "EmailConfigurationSetName", { value: config.configurationSet });
|
|
53
|
+
new CfnOutput(stack, "EmailEventTopicName", { value: config.eventTopicName });
|
|
54
|
+
return { PAPYRUS_EMAIL_FROM_ADDRESS: `${config.senderLocalPart}@${config.sendingDomain}`,
|
|
55
|
+
PAPYRUS_EMAIL_IDENTITY_ARN: identityArn, PAPYRUS_EMAIL_CONFIGURATION_SET: config.configurationSet,
|
|
56
|
+
PAPYRUS_EMAIL_EVENT_TOPIC_NAME: config.eventTopicName };
|
|
57
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { App } from "aws-cdk-lib";
|
|
3
|
+
import { Template } from "aws-cdk-lib/assertions";
|
|
4
|
+
import { AmplifyAppShellStack } from "./amplify-app-shell.js";
|
|
5
|
+
import { parseSiteConfig } from "./site-config.js";
|
|
6
|
+
const base = () => ({
|
|
7
|
+
siteId: "example", repository: "https://github.com/example/publication", brand: "example",
|
|
8
|
+
frontend: "markus-static", papyrusVersion: "5.32.0", autoDeploy: false,
|
|
9
|
+
cms: { cognitoDomainPrefix: "example-cms", staging: false,
|
|
10
|
+
environment: { PAPYRUS_OAUTH_REDIRECT_URLS: "http://localhost:3001/" } },
|
|
11
|
+
});
|
|
12
|
+
function synth(raw) {
|
|
13
|
+
const app = new App();
|
|
14
|
+
return Template.fromStack(new AmplifyAppShellStack(app, "EmailTest", parseSiteConfig(raw), {
|
|
15
|
+
env: { account: "123456789012", region: "us-east-1" },
|
|
16
|
+
}));
|
|
17
|
+
}
|
|
18
|
+
const checks = {
|
|
19
|
+
managed() {
|
|
20
|
+
const template = synth({ ...base(), email: { mode: "managed", sendingDomain: "news.example.test" } });
|
|
21
|
+
template.resourceCountIs("AWS::SES::EmailIdentity", 1);
|
|
22
|
+
template.resourceCountIs("AWS::SES::ConfigurationSet", 1);
|
|
23
|
+
template.resourceCountIs("AWS::Route53::RecordSet", 0);
|
|
24
|
+
template.resourceCountIs("AWS::SES::ReceiptRuleSet", 0);
|
|
25
|
+
template.hasResourceProperties("AWS::SES::EmailIdentity", { EmailIdentity: "news.example.test" });
|
|
26
|
+
template.hasResourceProperties("AWS::SNS::Topic", { TopicName: "papyrus-newsletter-events-example" });
|
|
27
|
+
template.hasResourceProperties("AWS::SES::ConfigurationSetEventDestination", {
|
|
28
|
+
EventDestination: { Name: "cloudwatch-metrics", MatchingEventTypes: ["SEND", "BOUNCE", "COMPLAINT"],
|
|
29
|
+
CloudWatchDestination: { DimensionConfigurations: [{ DimensionName: "ses:configuration-set",
|
|
30
|
+
DimensionValueSource: "MESSAGE_TAG", DefaultDimensionValue: "papyrus-example" }] } },
|
|
31
|
+
});
|
|
32
|
+
const text = JSON.stringify(template.toJSON());
|
|
33
|
+
assert.ok(text.includes("PAPYRUS_EMAIL_FROM_ADDRESS"));
|
|
34
|
+
assert.ok(text.includes("notifications@news.example.test"));
|
|
35
|
+
assert.ok(text.includes("EmailDkimRecord1Name"));
|
|
36
|
+
assert.ok(!text.includes("ses:SendEmail"), "identity setup does not grant unsolicited sending authority");
|
|
37
|
+
},
|
|
38
|
+
existing() {
|
|
39
|
+
const template = synth({ ...base(), email: { mode: "existing", sendingDomain: "news.example.test",
|
|
40
|
+
configurationSet: "already-owned", eventTopicName: "already-owned-events" } });
|
|
41
|
+
template.resourceCountIs("AWS::SES::EmailIdentity", 0);
|
|
42
|
+
template.resourceCountIs("AWS::SES::ConfigurationSet", 0);
|
|
43
|
+
template.resourceCountIs("AWS::SNS::Topic", 0);
|
|
44
|
+
assert.ok(JSON.stringify(template.toJSON()).includes("already-owned"));
|
|
45
|
+
},
|
|
46
|
+
static() {
|
|
47
|
+
const raw = { ...base(), reader: { buildCommand: "make site", baseDirectory: "dist" } };
|
|
48
|
+
delete raw.cms;
|
|
49
|
+
const template = synth(raw);
|
|
50
|
+
template.resourceCountIs("AWS::SES::EmailIdentity", 0);
|
|
51
|
+
template.resourceCountIs("AWS::SES::ConfigurationSet", 0);
|
|
52
|
+
template.resourceCountIs("AWS::SES::ReceiptRule", 0);
|
|
53
|
+
},
|
|
54
|
+
missing() {
|
|
55
|
+
assert.throws(() => parseSiteConfig(base()), /email.*required.*cloud/i);
|
|
56
|
+
assert.throws(() => parseSiteConfig({ ...base(), email: { mode: "existing", sendingDomain: "news.example.test" } }), /email.configurationSet/);
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
export function runCloudEmailTests(selected) {
|
|
60
|
+
if (selected) {
|
|
61
|
+
assert.ok(checks[selected], selected);
|
|
62
|
+
checks[selected]();
|
|
63
|
+
}
|
|
64
|
+
else
|
|
65
|
+
for (const check of Object.values(checks))
|
|
66
|
+
check();
|
|
67
|
+
console.log("cloud email infrastructure: ok");
|
|
68
|
+
}
|
|
69
|
+
runCloudEmailTests(process.argv[2]);
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { App } from "aws-cdk-lib";
|
|
3
|
+
import { Template } from "aws-cdk-lib/assertions";
|
|
4
|
+
import { AmplifyAppShellStack } from "./amplify-app-shell";
|
|
5
|
+
import { parseSiteConfig } from "./site-config";
|
|
6
|
+
|
|
7
|
+
const base = () => ({
|
|
8
|
+
siteId: "example", repository: "https://github.com/example/publication", brand: "example",
|
|
9
|
+
frontend: "markus-static", papyrusVersion: "5.32.0", autoDeploy: false,
|
|
10
|
+
cms: { cognitoDomainPrefix: "example-cms", staging: false,
|
|
11
|
+
environment: { PAPYRUS_OAUTH_REDIRECT_URLS: "http://localhost:3001/" } },
|
|
12
|
+
});
|
|
13
|
+
function synth(raw: unknown) {
|
|
14
|
+
const app = new App();
|
|
15
|
+
return Template.fromStack(new AmplifyAppShellStack(app, "EmailTest", parseSiteConfig(raw), {
|
|
16
|
+
env: { account: "123456789012", region: "us-east-1" },
|
|
17
|
+
}));
|
|
18
|
+
}
|
|
19
|
+
const checks: Record<string, () => void> = {
|
|
20
|
+
managed() {
|
|
21
|
+
const template = synth({ ...base(), email: { mode: "managed", sendingDomain: "news.example.test" } });
|
|
22
|
+
template.resourceCountIs("AWS::SES::EmailIdentity", 1);
|
|
23
|
+
template.resourceCountIs("AWS::SES::ConfigurationSet", 1);
|
|
24
|
+
template.resourceCountIs("AWS::Route53::RecordSet", 0);
|
|
25
|
+
template.resourceCountIs("AWS::SES::ReceiptRuleSet", 0);
|
|
26
|
+
template.hasResourceProperties("AWS::SES::EmailIdentity", { EmailIdentity: "news.example.test" });
|
|
27
|
+
template.hasResourceProperties("AWS::SNS::Topic", { TopicName: "papyrus-newsletter-events-example" });
|
|
28
|
+
template.hasResourceProperties("AWS::SES::ConfigurationSetEventDestination", {
|
|
29
|
+
EventDestination: { Name: "cloudwatch-metrics", MatchingEventTypes: ["SEND", "BOUNCE", "COMPLAINT"],
|
|
30
|
+
CloudWatchDestination: { DimensionConfigurations: [{ DimensionName: "ses:configuration-set",
|
|
31
|
+
DimensionValueSource: "MESSAGE_TAG", DefaultDimensionValue: "papyrus-example" }] } },
|
|
32
|
+
});
|
|
33
|
+
const text = JSON.stringify(template.toJSON());
|
|
34
|
+
assert.ok(text.includes("PAPYRUS_EMAIL_FROM_ADDRESS"));
|
|
35
|
+
assert.ok(text.includes("notifications@news.example.test"));
|
|
36
|
+
assert.ok(text.includes("EmailDkimRecord1Name"));
|
|
37
|
+
assert.ok(!text.includes("ses:SendEmail"), "identity setup does not grant unsolicited sending authority");
|
|
38
|
+
},
|
|
39
|
+
existing() {
|
|
40
|
+
const template = synth({ ...base(), email: { mode: "existing", sendingDomain: "news.example.test",
|
|
41
|
+
configurationSet: "already-owned", eventTopicName: "already-owned-events" } });
|
|
42
|
+
template.resourceCountIs("AWS::SES::EmailIdentity", 0);
|
|
43
|
+
template.resourceCountIs("AWS::SES::ConfigurationSet", 0);
|
|
44
|
+
template.resourceCountIs("AWS::SNS::Topic", 0);
|
|
45
|
+
assert.ok(JSON.stringify(template.toJSON()).includes("already-owned"));
|
|
46
|
+
},
|
|
47
|
+
static() {
|
|
48
|
+
const raw: Record<string, unknown> = { ...base(), reader: { buildCommand: "make site", baseDirectory: "dist" } };
|
|
49
|
+
delete raw.cms;
|
|
50
|
+
const template = synth(raw);
|
|
51
|
+
template.resourceCountIs("AWS::SES::EmailIdentity", 0);
|
|
52
|
+
template.resourceCountIs("AWS::SES::ConfigurationSet", 0);
|
|
53
|
+
template.resourceCountIs("AWS::SES::ReceiptRule", 0);
|
|
54
|
+
},
|
|
55
|
+
missing() {
|
|
56
|
+
assert.throws(() => parseSiteConfig(base()), /email.*required.*cloud/i);
|
|
57
|
+
assert.throws(() => parseSiteConfig({ ...base(), email: { mode: "existing", sendingDomain: "news.example.test" } }), /email.configurationSet/);
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
export function runCloudEmailTests(selected?: string): void {
|
|
61
|
+
if (selected) { assert.ok(checks[selected], selected); checks[selected](); }
|
|
62
|
+
else for (const check of Object.values(checks)) check();
|
|
63
|
+
console.log("cloud email infrastructure: ok");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
runCloudEmailTests(process.argv[2]);
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { ArnFormat, CfnOutput, RemovalPolicy, Stack } from "aws-cdk-lib";
|
|
2
|
+
import * as iam from "aws-cdk-lib/aws-iam";
|
|
3
|
+
import * as ses from "aws-cdk-lib/aws-ses";
|
|
4
|
+
import * as sns from "aws-cdk-lib/aws-sns";
|
|
5
|
+
|
|
6
|
+
import type { CloudEmailConfig } from "./cloud-email-config";
|
|
7
|
+
|
|
8
|
+
/** No DNS mutation, sending grant or receipt activation. Existing identities remain with their owner. */
|
|
9
|
+
export function addCloudEmail(stack: Stack, config: CloudEmailConfig): Record<string, string> {
|
|
10
|
+
if (config.mode === "managed") {
|
|
11
|
+
const configurationSet = new ses.CfnConfigurationSet(stack, "CloudEmailConfigurationSet", {
|
|
12
|
+
name: config.configurationSet,
|
|
13
|
+
reputationOptions: { reputationMetricsEnabled: true },
|
|
14
|
+
suppressionOptions: { suppressedReasons: ["BOUNCE", "COMPLAINT"] },
|
|
15
|
+
sendingOptions: { sendingEnabled: true },
|
|
16
|
+
});
|
|
17
|
+
configurationSet.applyRemovalPolicy(RemovalPolicy.RETAIN);
|
|
18
|
+
const identity = new ses.CfnEmailIdentity(stack, "CloudEmailIdentity", {
|
|
19
|
+
emailIdentity: config.sendingDomain,
|
|
20
|
+
dkimSigningAttributes: { nextSigningKeyLength: "RSA_2048_BIT" },
|
|
21
|
+
configurationSetAttributes: { configurationSetName: configurationSet.ref },
|
|
22
|
+
});
|
|
23
|
+
identity.applyRemovalPolicy(RemovalPolicy.RETAIN);
|
|
24
|
+
const topic = new sns.Topic(stack, "CloudEmailEvents", { topicName: config.eventTopicName });
|
|
25
|
+
topic.applyRemovalPolicy(RemovalPolicy.RETAIN);
|
|
26
|
+
topic.addToResourcePolicy(new iam.PolicyStatement({
|
|
27
|
+
principals: [new iam.ServicePrincipal("ses.amazonaws.com")], actions: ["sns:Publish"],
|
|
28
|
+
resources: [topic.topicArn], conditions: { StringEquals: { "aws:SourceAccount": stack.account } },
|
|
29
|
+
}));
|
|
30
|
+
const events = new ses.CfnConfigurationSetEventDestination(stack, "CloudEmailEventDestination", {
|
|
31
|
+
configurationSetName: configurationSet.ref,
|
|
32
|
+
eventDestination: { name: "delivery-events", enabled: true,
|
|
33
|
+
matchingEventTypes: ["SEND", "DELIVERY", "BOUNCE", "COMPLAINT", "REJECT", "RENDERING_FAILURE", "DELIVERY_DELAY"],
|
|
34
|
+
snsDestination: { topicArn: topic.topicArn } },
|
|
35
|
+
});
|
|
36
|
+
events.node.addDependency(topic);
|
|
37
|
+
// These dimensions also feed the newsletter consumer's existing bounce/complaint alarms.
|
|
38
|
+
new ses.CfnConfigurationSetEventDestination(stack, "CloudEmailMetrics", {
|
|
39
|
+
configurationSetName: configurationSet.ref,
|
|
40
|
+
eventDestination: { name: "cloudwatch-metrics", enabled: true,
|
|
41
|
+
matchingEventTypes: ["SEND", "BOUNCE", "COMPLAINT"],
|
|
42
|
+
cloudWatchDestination: { dimensionConfigurations: [{ dimensionName: "ses:configuration-set",
|
|
43
|
+
dimensionValueSource: "MESSAGE_TAG", defaultDimensionValue: config.configurationSet }] } },
|
|
44
|
+
});
|
|
45
|
+
const records = [[identity.attrDkimDnsTokenName1, identity.attrDkimDnsTokenValue1],
|
|
46
|
+
[identity.attrDkimDnsTokenName2, identity.attrDkimDnsTokenValue2],
|
|
47
|
+
[identity.attrDkimDnsTokenName3, identity.attrDkimDnsTokenValue3]];
|
|
48
|
+
records.forEach(([name, value], index) => {
|
|
49
|
+
new CfnOutput(stack, `EmailDkimRecord${index + 1}Name`, { value: name, description: "CNAME name; DNS change requires separate approval" });
|
|
50
|
+
new CfnOutput(stack, `EmailDkimRecord${index + 1}Value`, { value });
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
const identityArn = stack.formatArn({ service: "ses", resource: "identity", resourceName: config.sendingDomain, arnFormat: ArnFormat.SLASH_RESOURCE_NAME });
|
|
54
|
+
new CfnOutput(stack, "EmailIdentityArn", { value: identityArn });
|
|
55
|
+
new CfnOutput(stack, "EmailConfigurationSetName", { value: config.configurationSet });
|
|
56
|
+
new CfnOutput(stack, "EmailEventTopicName", { value: config.eventTopicName });
|
|
57
|
+
return { PAPYRUS_EMAIL_FROM_ADDRESS: `${config.senderLocalPart}@${config.sendingDomain}`,
|
|
58
|
+
PAPYRUS_EMAIL_IDENTITY_ARN: identityArn, PAPYRUS_EMAIL_CONFIGURATION_SET: config.configurationSet,
|
|
59
|
+
PAPYRUS_EMAIL_EVENT_TOPIC_NAME: config.eventTopicName };
|
|
60
|
+
}
|
package/infra/site-config.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { parseCloudEmail } from "./cloud-email-config.js";
|
|
1
2
|
export const READER_REDIRECT_STATUSES = ["301", "302", "200", "404", "404-200"];
|
|
2
3
|
export function hasCmsApp(config) {
|
|
3
4
|
return config.cms !== undefined;
|
|
@@ -35,7 +36,7 @@ const GITHUB_REPO_PATTERN = /^[A-Za-z0-9_.-]+$/;
|
|
|
35
36
|
const COGNITO_DOMAIN_PREFIX_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
|
|
36
37
|
const COGNITO_RESERVED_WORDS = ["aws", "amazon", "cognito"];
|
|
37
38
|
const PROSPECTUS_ACCOUNTS_API_STAGE_ARN_PATTERN = /^arn:aws:execute-api:[a-z0-9-]+:\d{12}:[a-z0-9]+\/api$/;
|
|
38
|
-
const TEMPLATE_MANAGED_ENVIRONMENT_KEYS = ["PAPYRUS_COGNITO_DOMAIN_PREFIX", "PAPYRUS_APPLY_COGNITO_DOMAIN_PREFIX", "PAPYRUS_DISABLE_GOOGLE_OAUTH", "PAPYRUS_REVALIDATE_SECRET_PARAMETER", "PAPYRUS_PROSPECTUS_ACCOUNTS_ENDPOINT", "PAPYRUS_PROSPECTUS_SESSION_SECRET_PARAMETER", "PAPYRUS_PROSPECTUS_SITE_PRODUCT", "PAPYRUS_READER_POLL_SECRET_PARAMETER", "PAPYRUS_SITE_ORIGIN"];
|
|
39
|
+
const TEMPLATE_MANAGED_ENVIRONMENT_KEYS = ["PAPYRUS_EMAIL_FROM_ADDRESS", "PAPYRUS_EMAIL_IDENTITY_ARN", "PAPYRUS_EMAIL_CONFIGURATION_SET", "PAPYRUS_EMAIL_EVENT_TOPIC_NAME", "PAPYRUS_COGNITO_DOMAIN_PREFIX", "PAPYRUS_APPLY_COGNITO_DOMAIN_PREFIX", "PAPYRUS_DISABLE_GOOGLE_OAUTH", "PAPYRUS_REVALIDATE_SECRET_PARAMETER", "PAPYRUS_PROSPECTUS_ACCOUNTS_ENDPOINT", "PAPYRUS_PROSPECTUS_SESSION_SECRET_PARAMETER", "PAPYRUS_PROSPECTUS_SITE_PRODUCT", "PAPYRUS_READER_POLL_SECRET_PARAMETER", "PAPYRUS_SITE_ORIGIN"];
|
|
39
40
|
/** Routes Prospectus admits for a registered Papyrus site (Prospectus accounts/papyrus-sites.ts). */
|
|
40
41
|
export const PROSPECTUS_PAPYRUS_SITE_ROUTES = [
|
|
41
42
|
"GET/v1/identities/me",
|
|
@@ -350,7 +351,7 @@ function parseAws(value) {
|
|
|
350
351
|
}
|
|
351
352
|
export function parseSiteConfig(raw) {
|
|
352
353
|
const record = requireObject(raw, "site");
|
|
353
|
-
rejectUnknownKeys(record, ["siteId", "repository", "repositoryAccessTokenSecretArn", "brand", "frontend", "hostedZoneId", "stackName", "aws", "cms", "reader", "papyrusVersion", "storagePreviewPrefix", "knowledgeRun", "autoDeploy", "github"], "site");
|
|
354
|
+
rejectUnknownKeys(record, ["siteId", "repository", "repositoryAccessTokenSecretArn", "brand", "frontend", "hostedZoneId", "stackName", "aws", "cms", "email", "reader", "papyrusVersion", "storagePreviewPrefix", "knowledgeRun", "autoDeploy", "github"], "site");
|
|
354
355
|
const siteId = requireString(record.siteId, "siteId", SITE_ID_PATTERN, "kebab-case");
|
|
355
356
|
const repository = requireString(record.repository, "repository", REPOSITORY_PATTERN, "https://github.com/<org>/<repo>");
|
|
356
357
|
const repositoryAccessTokenSecretArn = record.repositoryAccessTokenSecretArn === undefined
|
|
@@ -460,6 +461,7 @@ export function parseSiteConfig(raw) {
|
|
|
460
461
|
stackName,
|
|
461
462
|
aws,
|
|
462
463
|
cms,
|
|
464
|
+
email: parseCloudEmail(record.email, cms !== undefined, siteId),
|
|
463
465
|
reader,
|
|
464
466
|
papyrusVersion,
|
|
465
467
|
storagePreviewPrefix,
|
package/infra/site-config.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { parseCloudEmail, type CloudEmailConfig } from "./cloud-email-config";
|
|
2
|
+
|
|
1
3
|
export type CmsRedirect = {
|
|
2
4
|
source: string;
|
|
3
5
|
status: 301 | 302;
|
|
@@ -63,6 +65,8 @@ export type AmplifyAppShellSiteConfig = {
|
|
|
63
65
|
stackName?: string;
|
|
64
66
|
aws?: SiteAwsConfig;
|
|
65
67
|
cms?: CmsConfig;
|
|
68
|
+
/** Required for cloud CMS sites; independent of newsletter feature enablement. */
|
|
69
|
+
email?: CloudEmailConfig;
|
|
66
70
|
reader?: {
|
|
67
71
|
appName?: string;
|
|
68
72
|
domainName?: string;
|
|
@@ -157,7 +161,7 @@ const GITHUB_REPO_PATTERN = /^[A-Za-z0-9_.-]+$/;
|
|
|
157
161
|
const COGNITO_DOMAIN_PREFIX_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
|
|
158
162
|
const COGNITO_RESERVED_WORDS = ["aws", "amazon", "cognito"];
|
|
159
163
|
const PROSPECTUS_ACCOUNTS_API_STAGE_ARN_PATTERN = /^arn:aws:execute-api:[a-z0-9-]+:\d{12}:[a-z0-9]+\/api$/;
|
|
160
|
-
const TEMPLATE_MANAGED_ENVIRONMENT_KEYS = ["PAPYRUS_COGNITO_DOMAIN_PREFIX", "PAPYRUS_APPLY_COGNITO_DOMAIN_PREFIX", "PAPYRUS_DISABLE_GOOGLE_OAUTH", "PAPYRUS_REVALIDATE_SECRET_PARAMETER", "PAPYRUS_PROSPECTUS_ACCOUNTS_ENDPOINT", "PAPYRUS_PROSPECTUS_SESSION_SECRET_PARAMETER", "PAPYRUS_PROSPECTUS_SITE_PRODUCT", "PAPYRUS_READER_POLL_SECRET_PARAMETER", "PAPYRUS_SITE_ORIGIN"];
|
|
164
|
+
const TEMPLATE_MANAGED_ENVIRONMENT_KEYS = ["PAPYRUS_EMAIL_FROM_ADDRESS", "PAPYRUS_EMAIL_IDENTITY_ARN", "PAPYRUS_EMAIL_CONFIGURATION_SET", "PAPYRUS_EMAIL_EVENT_TOPIC_NAME", "PAPYRUS_COGNITO_DOMAIN_PREFIX", "PAPYRUS_APPLY_COGNITO_DOMAIN_PREFIX", "PAPYRUS_DISABLE_GOOGLE_OAUTH", "PAPYRUS_REVALIDATE_SECRET_PARAMETER", "PAPYRUS_PROSPECTUS_ACCOUNTS_ENDPOINT", "PAPYRUS_PROSPECTUS_SESSION_SECRET_PARAMETER", "PAPYRUS_PROSPECTUS_SITE_PRODUCT", "PAPYRUS_READER_POLL_SECRET_PARAMETER", "PAPYRUS_SITE_ORIGIN"];
|
|
161
165
|
|
|
162
166
|
/** Routes Prospectus admits for a registered Papyrus site (Prospectus accounts/papyrus-sites.ts). */
|
|
163
167
|
export const PROSPECTUS_PAPYRUS_SITE_ROUTES = [
|
|
@@ -490,7 +494,7 @@ export function parseSiteConfig(raw: unknown): AmplifyAppShellSiteConfig {
|
|
|
490
494
|
const record = requireObject(raw, "site");
|
|
491
495
|
rejectUnknownKeys(
|
|
492
496
|
record,
|
|
493
|
-
["siteId", "repository", "repositoryAccessTokenSecretArn", "brand", "frontend", "hostedZoneId", "stackName", "aws", "cms", "reader", "papyrusVersion", "storagePreviewPrefix", "knowledgeRun", "autoDeploy", "github"],
|
|
497
|
+
["siteId", "repository", "repositoryAccessTokenSecretArn", "brand", "frontend", "hostedZoneId", "stackName", "aws", "cms", "email", "reader", "papyrusVersion", "storagePreviewPrefix", "knowledgeRun", "autoDeploy", "github"],
|
|
494
498
|
"site",
|
|
495
499
|
);
|
|
496
500
|
|
|
@@ -600,6 +604,7 @@ export function parseSiteConfig(raw: unknown): AmplifyAppShellSiteConfig {
|
|
|
600
604
|
stackName,
|
|
601
605
|
aws,
|
|
602
606
|
cms,
|
|
607
|
+
email: parseCloudEmail(record.email, cms !== undefined, siteId),
|
|
603
608
|
reader,
|
|
604
609
|
papyrusVersion,
|
|
605
610
|
storagePreviewPrefix,
|
package/lib/news-desk-session.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import type { CategorySteeringDashboard } from "./category-repository";
|
|
2
|
-
import { createEmptyCategorySteeringDashboard } from "./category-dashboard";
|
|
3
2
|
import type { ReaderAuthSnapshot } from "../components/reader-auth-state";
|
|
4
3
|
|
|
5
4
|
export type NewsDeskShellPhase =
|
|
@@ -59,26 +58,6 @@ export function beginDeskLoad(state: NewsDeskShellState, auth: ReaderAuthSnapsho
|
|
|
59
58
|
};
|
|
60
59
|
}
|
|
61
60
|
|
|
62
|
-
export function beginAuthenticatedDeskHydration(
|
|
63
|
-
state: NewsDeskShellState,
|
|
64
|
-
auth: ReaderAuthSnapshot,
|
|
65
|
-
options: { canManageUsers: boolean },
|
|
66
|
-
): NewsDeskShellState {
|
|
67
|
-
const dashboard = state.dashboard ?? {
|
|
68
|
-
...createEmptyCategorySteeringDashboard(),
|
|
69
|
-
isPublicSkeleton: false,
|
|
70
|
-
canManageUsers: options.canManageUsers,
|
|
71
|
-
};
|
|
72
|
-
return {
|
|
73
|
-
...state,
|
|
74
|
-
phase: state.dashboard ? "refreshing" : "loadingDesk",
|
|
75
|
-
auth,
|
|
76
|
-
dashboard,
|
|
77
|
-
stale: false,
|
|
78
|
-
error: null,
|
|
79
|
-
};
|
|
80
|
-
}
|
|
81
|
-
|
|
82
61
|
export function resolveSignedOut(state: NewsDeskShellState, auth: ReaderAuthSnapshot): NewsDeskShellState {
|
|
83
62
|
return {
|
|
84
63
|
...state,
|
|
@@ -143,6 +122,18 @@ export function resolveDashboardFailure(
|
|
|
143
122
|
};
|
|
144
123
|
}
|
|
145
124
|
|
|
125
|
+
/** An unverified session must never leave private records on screen. */
|
|
126
|
+
export function resolveAccessFailure(state: NewsDeskShellState, error: string): NewsDeskShellState {
|
|
127
|
+
return {
|
|
128
|
+
...state,
|
|
129
|
+
phase: "error",
|
|
130
|
+
auth: { status: "signedOut", label: "Signed out" },
|
|
131
|
+
dashboard: null,
|
|
132
|
+
stale: false,
|
|
133
|
+
error,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
146
137
|
export function patchDashboard(
|
|
147
138
|
state: NewsDeskShellState,
|
|
148
139
|
patch: (dashboard: CategorySteeringDashboard) => CategorySteeringDashboard,
|
|
@@ -8,6 +8,7 @@ export type OrganizerDestination = {
|
|
|
8
8
|
};
|
|
9
9
|
export const ORGANIZER_NAV_GROUPS: readonly { title: string; items: readonly OrganizerDestination[] }[] = [
|
|
10
10
|
{ title: "Editorial work", items: [
|
|
11
|
+
{ key: "overview", label: "Overview", description: "See notifications and work needing attention.", path: "/newsroom", primary: true },
|
|
11
12
|
{ key: "articles", label: getContentItemName().plural, description: `Follow ${contentItemPluralLowercase} across their stages and open one to write or revise it.`, path: `/newsroom/${contentItemBoardPathSegment()}`, alsoActiveOnPaths: ["/newsroom/articles"], primary: true },
|
|
12
13
|
{ key: "assignments", label: "Assignments", description: "Follow research and reporting work across its statuses and claim it.", path: "/newsroom/assignments" },
|
|
13
14
|
{ key: "messages", label: "Messages", description: "Read and reply to newsroom conversations.", path: "/newsroom/messages", primary: true },
|
|
@@ -23,7 +24,7 @@ export const ORGANIZER_NAV_GROUPS: readonly { title: string; items: readonly Org
|
|
|
23
24
|
] },
|
|
24
25
|
];
|
|
25
26
|
const all = ORGANIZER_NAV_GROUPS.flatMap(group => group.items);
|
|
26
|
-
export const ORGANIZER_PRIMARY_NAV = ["articles", "references", "messages"].map(key => all.find(item => item.key === key)!);
|
|
27
|
+
export const ORGANIZER_PRIMARY_NAV = ["overview", "articles", "references", "messages"].map(key => all.find(item => item.key === key)!);
|
|
27
28
|
|
|
28
29
|
export const ORGANIZER_SETTINGS_DESTINATION = {
|
|
29
30
|
label: "Settings", path: "/newsroom/settings", description: "Theme, motion and interface preferences.",
|
package/package.json
CHANGED
|
@@ -108,6 +108,7 @@
|
|
|
108
108
|
"./components/newsroom-curation-provider": "./components/newsroom-curation-provider.tsx",
|
|
109
109
|
"./components/newsroom-ops-overview": "./components/newsroom-ops-overview.tsx",
|
|
110
110
|
"./components/newsroom-ops-shell": "./components/newsroom-ops-shell.tsx",
|
|
111
|
+
"./components/newsroom-overview-skeleton": "./components/newsroom-overview-skeleton.tsx",
|
|
111
112
|
"./components/newsroom-references-view": "./components/newsroom-references-view.tsx",
|
|
112
113
|
"./components/newsroom-theme-fonts/broadsheet-green": "./components/newsroom-theme-fonts/broadsheet-green.tsx",
|
|
113
114
|
"./components/newsroom-theme-fonts/newspaper-gothic": "./components/newsroom-theme-fonts/newspaper-gothic.tsx",
|
|
@@ -447,7 +448,7 @@
|
|
|
447
448
|
"optional": true
|
|
448
449
|
}
|
|
449
450
|
},
|
|
450
|
-
"version": "5.
|
|
451
|
+
"version": "5.34.0",
|
|
451
452
|
"dependencies": {
|
|
452
453
|
"@aws-amplify/adapter-nextjs": "^1.7.3",
|
|
453
454
|
"@aws-crypto/sha256-js": "5.2.0",
|
package/routes.manifest.json
CHANGED