@anthusai/papyrus 1.14.0 → 1.16.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/data/resource.js +62 -0
- package/amplify/data/resource.ts +68 -0
- package/amplify/data/schema.js +62 -0
- package/amplify/data/schema.ts +68 -0
- package/amplify/functions/category-action/handler.ts +83 -8
- package/amplify/functions/shared/curation-delegation-flow.ts +147 -0
- package/amplify/functions/shared/curation-delegation.ts +50 -2
- package/app/api/auth/prospectus/accounts/route.ts +9 -0
- package/components/administration-delegations-panel.tsx +52 -100
- package/components/curation-delegation-client.ts +46 -23
- package/components/newsroom-organizer.css +21 -0
- package/components/prospectus-account-chooser.tsx +34 -1
- package/infra/amplify-app-shell.js +17 -3
- package/infra/amplify-app-shell.ts +18 -3
- package/infra/site-config.js +6 -1
- package/infra/site-config.ts +7 -1
- package/lib/curation-delegation-helpers.ts +35 -25
- package/lib/prospectus-account-client.ts +26 -0
- package/lib/prospectus-sign-in-routes.ts +45 -0
- package/lib/prospectus-site-identity.ts +13 -1
- package/package.json +2 -1
- package/routes.manifest.json +12 -1
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { NextRequest } from "next/server";
|
|
2
|
+
import { handleProspectusPublishingAccountCreation } from "../../../../../lib/prospectus-sign-in-routes";
|
|
3
|
+
|
|
4
|
+
export const dynamic = "force-dynamic";
|
|
5
|
+
export const runtime = "nodejs";
|
|
6
|
+
|
|
7
|
+
export function POST(request: NextRequest) {
|
|
8
|
+
return handleProspectusPublishingAccountCreation(request);
|
|
9
|
+
}
|
|
@@ -1,41 +1,22 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import { useEffect, useRef, useState } from "react";
|
|
4
|
-
import { fetchAuthSession } from "aws-amplify/auth";
|
|
5
|
-
import { isLocalPreviewEnabled } from "../lib/local-preview";
|
|
6
4
|
import {
|
|
7
|
-
|
|
8
|
-
MAXIMUM_DELEGATION_HOURS,
|
|
9
|
-
MINIMUM_DELEGATION_HOURS,
|
|
10
|
-
buildDelegatedCommandLine,
|
|
11
|
-
delegationExpiryText,
|
|
5
|
+
delegationStateText,
|
|
12
6
|
describeDelegationError,
|
|
13
|
-
|
|
7
|
+
isPendingDelegation,
|
|
8
|
+
pendingCountdownText,
|
|
9
|
+
requestedHoursText,
|
|
14
10
|
type CurationDelegationRecord,
|
|
15
11
|
} from "../lib/curation-delegation-helpers";
|
|
16
|
-
import {
|
|
12
|
+
import { approveCurationDelegation, denyCurationDelegation, listCurationDelegations, revokeCurationDelegation } from "./curation-delegation-client";
|
|
17
13
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
async function readSignedInEmail(): Promise<string> {
|
|
21
|
-
if (isLocalPreviewEnabled()) return "ryan@example.com";
|
|
22
|
-
try {
|
|
23
|
-
const session = await fetchAuthSession();
|
|
24
|
-
const email = session.tokens?.idToken?.payload?.email;
|
|
25
|
-
return typeof email === "string" ? email : "";
|
|
26
|
-
} catch {
|
|
27
|
-
return "";
|
|
28
|
-
}
|
|
29
|
-
}
|
|
14
|
+
const REFRESH_MILLISECONDS = 10_000;
|
|
30
15
|
|
|
31
16
|
export function AdministrationDelegationsPanel({ disabled }: { disabled: boolean }) {
|
|
32
|
-
const [
|
|
33
|
-
const [hoursText, setHoursText] = useState(String(DEFAULT_DELEGATION_HOURS));
|
|
17
|
+
const [busyCode, setBusyCode] = useState<string | null>(null);
|
|
34
18
|
const [busy, setBusy] = useState(false);
|
|
35
19
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
|
36
|
-
const [issued, setIssued] = useState<IssuedToken | null>(null);
|
|
37
|
-
const [copied, setCopied] = useState<"token" | "command" | null>(null);
|
|
38
|
-
const [email, setEmail] = useState("");
|
|
39
20
|
const [delegations, setDelegations] = useState<CurationDelegationRecord[]>([]);
|
|
40
21
|
const [confirmingRevokeId, setConfirmingRevokeId] = useState<string | null>(null);
|
|
41
22
|
const [nowMilliseconds, setNowMilliseconds] = useState(() => Date.now());
|
|
@@ -43,15 +24,13 @@ export function AdministrationDelegationsPanel({ disabled }: { disabled: boolean
|
|
|
43
24
|
|
|
44
25
|
useEffect(() => {
|
|
45
26
|
mounted.current = true;
|
|
46
|
-
void readSignedInEmail().then((value) => {
|
|
47
|
-
if (mounted.current) setEmail(value);
|
|
48
|
-
});
|
|
49
27
|
void refreshDelegations();
|
|
50
|
-
const
|
|
28
|
+
const refreshTimer = window.setInterval(() => void refreshDelegations(), REFRESH_MILLISECONDS);
|
|
29
|
+
const clockTimer = window.setInterval(() => setNowMilliseconds(Date.now()), 1_000);
|
|
51
30
|
return () => {
|
|
52
31
|
mounted.current = false;
|
|
53
|
-
window.clearInterval(
|
|
54
|
-
|
|
32
|
+
window.clearInterval(refreshTimer);
|
|
33
|
+
window.clearInterval(clockTimer);
|
|
55
34
|
};
|
|
56
35
|
}, []);
|
|
57
36
|
|
|
@@ -64,25 +43,17 @@ export function AdministrationDelegationsPanel({ disabled }: { disabled: boolean
|
|
|
64
43
|
}
|
|
65
44
|
}
|
|
66
45
|
|
|
67
|
-
async function
|
|
68
|
-
|
|
69
|
-
if (!validation.ok) {
|
|
70
|
-
setErrorMessage(validation.message);
|
|
71
|
-
return;
|
|
72
|
-
}
|
|
73
|
-
setBusy(true);
|
|
46
|
+
async function decide(userCode: string, decision: "approve" | "deny") {
|
|
47
|
+
setBusyCode(userCode);
|
|
74
48
|
setErrorMessage(null);
|
|
75
49
|
try {
|
|
76
|
-
|
|
77
|
-
setIssued({ agentLabel: created.agentLabel, token: created.token });
|
|
78
|
-
setCopied(null);
|
|
50
|
+
await (decision === "approve" ? approveCurationDelegation(userCode) : denyCurationDelegation(userCode));
|
|
79
51
|
await refreshDelegations();
|
|
80
|
-
setAgentLabelText("");
|
|
81
|
-
setNowMilliseconds(Date.now());
|
|
82
52
|
} catch (error) {
|
|
83
53
|
setErrorMessage(describeDelegationError(error));
|
|
54
|
+
await refreshDelegations();
|
|
84
55
|
} finally {
|
|
85
|
-
|
|
56
|
+
setBusyCode(null);
|
|
86
57
|
}
|
|
87
58
|
}
|
|
88
59
|
|
|
@@ -100,81 +71,62 @@ export function AdministrationDelegationsPanel({ disabled }: { disabled: boolean
|
|
|
100
71
|
}
|
|
101
72
|
}
|
|
102
73
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
await navigator.clipboard.writeText(text);
|
|
106
|
-
setCopied(kind);
|
|
107
|
-
} catch {
|
|
108
|
-
setErrorMessage("Copying was blocked by the browser. Select the text and copy it by hand.");
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
const commandLine = issued ? buildDelegatedCommandLine({ email, agentLabel: issued.agentLabel, token: issued.token }) : "";
|
|
74
|
+
const pending = delegations.filter((entry) => isPendingDelegation(entry, nowMilliseconds));
|
|
75
|
+
const others = delegations.filter((entry) => entry.state !== "pending");
|
|
113
76
|
|
|
114
77
|
return (
|
|
115
78
|
<div data-news-desk-admin-panel="delegations">
|
|
116
|
-
<article className="news-desk-doctrine-card">
|
|
79
|
+
<article className="news-desk-doctrine-card" data-delegation-pending-block>
|
|
117
80
|
<div className="news-desk-doctrine-card__header">
|
|
118
81
|
<div>
|
|
119
|
-
<h3>
|
|
120
|
-
<span>
|
|
82
|
+
<h3>Pending requests</h3>
|
|
83
|
+
<span>An agent asked to record your decisions. Check the code matches what it showed you.</span>
|
|
121
84
|
</div>
|
|
122
85
|
</div>
|
|
123
|
-
<p className="news-desk-delegation-
|
|
124
|
-
|
|
125
|
-
|
|
86
|
+
{errorMessage ? <p className="news-desk-delegation-error" role="alert" data-delegation-error>{errorMessage}</p> : null}
|
|
87
|
+
{pending.length === 0 ? (
|
|
88
|
+
<p className="news-desk-delegation-note" data-delegation-pending-empty>No agent is waiting for approval. A request appears here as soon as an agent asks.</p>
|
|
89
|
+
) : (
|
|
90
|
+
<ul className="news-desk-delegation-pending-list">
|
|
91
|
+
{pending.map((entry) => {
|
|
92
|
+
const userCode = entry.userCode ?? "";
|
|
93
|
+
const working = busyCode === userCode;
|
|
94
|
+
return (
|
|
95
|
+
<li key={entry.delegationId} data-delegation-pending={entry.delegationId}>
|
|
96
|
+
<div>
|
|
97
|
+
<strong>{entry.agentLabel}</strong>
|
|
98
|
+
<span>{requestedHoursText(entry.requestedHours)}</span>
|
|
99
|
+
<span className="news-desk-delegation-countdown" data-delegation-countdown>{pendingCountdownText(entry.pendingExpiresAt ?? entry.expiresAt, nowMilliseconds)}</span>
|
|
100
|
+
</div>
|
|
101
|
+
<code className="news-desk-delegation-code" data-delegation-user-code aria-label={`Code ${userCode.split("").join(" ")}`}>{userCode}</code>
|
|
102
|
+
<span className="news-desk-delegation-decision">
|
|
103
|
+
<button type="button" data-delegation-approve onClick={() => void decide(userCode, "approve")} disabled={disabled || working || busyCode !== null}>Approve</button>
|
|
104
|
+
<button type="button" data-delegation-deny onClick={() => void decide(userCode, "deny")} disabled={disabled || working || busyCode !== null}>Deny</button>
|
|
105
|
+
</span>
|
|
106
|
+
</li>
|
|
107
|
+
);
|
|
108
|
+
})}
|
|
109
|
+
</ul>
|
|
110
|
+
)}
|
|
126
111
|
<p className="news-desk-delegation-note" data-delegation-words-note>
|
|
127
|
-
|
|
112
|
+
While a delegation lasts, that agent's approvals and rejections are recorded under your name and marked as relayed, with the words you gave it kept with each record. You can revoke it at any moment.
|
|
128
113
|
</p>
|
|
129
|
-
<div className="news-desk-delegation-form">
|
|
130
|
-
<label className="news-desk-doctrine-card__field">
|
|
131
|
-
<span>Agent name</span>
|
|
132
|
-
<input value={agentLabelText} onChange={(event) => setAgentLabelText(event.target.value)} placeholder="curator-agent" autoComplete="off" disabled={disabled || busy} />
|
|
133
|
-
</label>
|
|
134
|
-
<label className="news-desk-doctrine-card__field">
|
|
135
|
-
<span>{`Hours (${MINIMUM_DELEGATION_HOURS} to ${MAXIMUM_DELEGATION_HOURS})`}</span>
|
|
136
|
-
<input value={hoursText} onChange={(event) => setHoursText(event.target.value)} inputMode="numeric" disabled={disabled || busy} />
|
|
137
|
-
</label>
|
|
138
|
-
<button type="button" className="news-desk-primary-action-button" data-delegation-create onClick={() => void handleCreate()} disabled={disabled || busy}>
|
|
139
|
-
{busy && !issued ? "Creating" : "Create delegation"}
|
|
140
|
-
</button>
|
|
141
|
-
</div>
|
|
142
|
-
{errorMessage ? <p className="news-desk-delegation-error" role="alert" data-delegation-error>{errorMessage}</p> : null}
|
|
143
114
|
</article>
|
|
144
115
|
|
|
145
|
-
{issued ? (
|
|
146
|
-
<article className="news-desk-delegation-token" data-delegation-token-box aria-live="polite">
|
|
147
|
-
<h3>Copy this token now</h3>
|
|
148
|
-
<p>This token is shown once. Papyrus does not keep it, and it cannot be shown again. If you lose it, revoke the delegation and create a new one.</p>
|
|
149
|
-
<div className="news-desk-delegation-token__row">
|
|
150
|
-
<code data-delegation-token>{issued.token}</code>
|
|
151
|
-
<button type="button" onClick={() => void copyText("token", issued.token)}>{copied === "token" ? "Copied" : "Copy token"}</button>
|
|
152
|
-
</div>
|
|
153
|
-
<p className="story-label">Command line for {issued.agentLabel}</p>
|
|
154
|
-
<div className="news-desk-delegation-token__row">
|
|
155
|
-
<code data-delegation-command>{commandLine}</code>
|
|
156
|
-
<button type="button" onClick={() => void copyText("command", commandLine)}>{copied === "command" ? "Copied" : "Copy command"}</button>
|
|
157
|
-
</div>
|
|
158
|
-
<div className="news-desk-doctrine-card__actions">
|
|
159
|
-
<button type="button" className="news-desk-primary-action-button" data-delegation-done onClick={() => { setIssued(null); setCopied(null); }}>Done</button>
|
|
160
|
-
</div>
|
|
161
|
-
</article>
|
|
162
|
-
) : null}
|
|
163
|
-
|
|
164
116
|
<article className="news-desk-doctrine-card">
|
|
165
117
|
<div className="news-desk-doctrine-card__header">
|
|
166
118
|
<div>
|
|
167
119
|
<h3>Delegations</h3>
|
|
168
|
-
<span>
|
|
120
|
+
<span>Decided requests, newest first</span>
|
|
169
121
|
</div>
|
|
170
122
|
</div>
|
|
171
|
-
{
|
|
123
|
+
{others.length === 0 ? (
|
|
172
124
|
<p className="news-desk-delegation-note" data-delegation-empty>You have not granted any delegations.</p>
|
|
173
125
|
) : (
|
|
174
126
|
<ul className="news-desk-delegation-list">
|
|
175
|
-
{
|
|
176
|
-
const stateText =
|
|
177
|
-
const live = !entry.revokedAt && stateText !== "Expired";
|
|
127
|
+
{others.map((entry) => {
|
|
128
|
+
const stateText = delegationStateText(entry, nowMilliseconds);
|
|
129
|
+
const live = entry.state === "approved" && !entry.revokedAt && stateText !== "Expired";
|
|
178
130
|
return (
|
|
179
131
|
<li key={entry.delegationId} data-delegation-row={entry.delegationId} data-delegation-live={live || undefined}>
|
|
180
132
|
<div>
|
|
@@ -7,20 +7,24 @@ import { configureAmplifyClient } from "./amplify-client-provider";
|
|
|
7
7
|
|
|
8
8
|
const USER_POOL_AUTH_MODE = "userPool";
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
const DELEGATION_FIELDS = "delegationId agentLabel grantorLabel expiresAt revokedAt state userCode requestedHours pendingExpiresAt approvedAt deniedAt";
|
|
11
11
|
|
|
12
|
-
const
|
|
13
|
-
mutation
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
12
|
+
const APPROVE_MUTATION = `
|
|
13
|
+
mutation ApproveCurationDelegation($userCode: String!) {
|
|
14
|
+
approveCurationDelegation(userCode: $userCode) { ${DELEGATION_FIELDS} }
|
|
15
|
+
}
|
|
16
|
+
`;
|
|
17
|
+
|
|
18
|
+
const DENY_MUTATION = `
|
|
19
|
+
mutation DenyCurationDelegation($userCode: String!) {
|
|
20
|
+
denyCurationDelegation(userCode: $userCode) { ${DELEGATION_FIELDS} }
|
|
17
21
|
}
|
|
18
22
|
`;
|
|
19
23
|
|
|
20
24
|
const REVOKE_MUTATION = `
|
|
21
25
|
mutation RevokeCurationDelegation($delegationId: ID!) {
|
|
22
26
|
revokeCurationDelegation(delegationId: $delegationId) {
|
|
23
|
-
|
|
27
|
+
${DELEGATION_FIELDS}
|
|
24
28
|
}
|
|
25
29
|
}
|
|
26
30
|
`;
|
|
@@ -28,12 +32,24 @@ const REVOKE_MUTATION = `
|
|
|
28
32
|
const LIST_QUERY = `
|
|
29
33
|
query ListCurationDelegations {
|
|
30
34
|
listCurationDelegations {
|
|
31
|
-
|
|
35
|
+
${DELEGATION_FIELDS}
|
|
32
36
|
}
|
|
33
37
|
}
|
|
34
38
|
`;
|
|
35
39
|
|
|
36
|
-
|
|
40
|
+
function previewPendingRequest(agentLabel: string, userCode: string, requestedHours: number, minutesLeft: number, id: string): CurationDelegationRecord {
|
|
41
|
+
const pendingExpiresAt = new Date(Date.now() + minutesLeft * 60_000).toISOString();
|
|
42
|
+
return { delegationId: id, agentLabel, grantorLabel: null, expiresAt: pendingExpiresAt, revokedAt: null, state: "pending", userCode, requestedHours, pendingExpiresAt, approvedAt: null, deniedAt: null };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const previewDelegations: CurationDelegationRecord[] = [
|
|
46
|
+
previewPendingRequest("e2e-check", "WDJB-MJHT", 1, 9.5, "curation-delegation-preview-pending-1"),
|
|
47
|
+
previewPendingRequest("section-curator", "KQPR-4XNZ", 8, 4, "curation-delegation-preview-pending-2"),
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
function normalizedCode(code: string): string {
|
|
51
|
+
return code.replace(/[^a-z0-9]/gi, "").toUpperCase();
|
|
52
|
+
}
|
|
37
53
|
|
|
38
54
|
async function runGraphql<T>(query: string, variables: Record<string, unknown>): Promise<T> {
|
|
39
55
|
configureAmplifyClient();
|
|
@@ -46,21 +62,27 @@ async function runGraphql<T>(query: string, variables: Record<string, unknown>):
|
|
|
46
62
|
return response.data;
|
|
47
63
|
}
|
|
48
64
|
|
|
49
|
-
export async function
|
|
65
|
+
export async function approveCurationDelegation(userCode: string): Promise<CurationDelegationRecord> {
|
|
50
66
|
if (isLocalPreviewEnabled()) {
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
67
|
+
const record = previewDelegations.find((entry) => entry.state === "pending" && normalizedCode(entry.userCode ?? "") === normalizedCode(userCode));
|
|
68
|
+
if (!record) throw new Error("That code was not found, has expired, or was already decided.");
|
|
69
|
+
const now = Date.now();
|
|
70
|
+
Object.assign(record, { state: "approved", userCode: null, grantorLabel: "ryan@example.com", approvedAt: new Date(now).toISOString(), expiresAt: new Date(now + (record.requestedHours ?? 1) * 3_600_000).toISOString() });
|
|
71
|
+
return { ...record };
|
|
72
|
+
}
|
|
73
|
+
const data = await runGraphql<{ approveCurationDelegation: CurationDelegationRecord }>(APPROVE_MUTATION, { userCode });
|
|
74
|
+
return data.approveCurationDelegation;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export async function denyCurationDelegation(userCode: string): Promise<CurationDelegationRecord> {
|
|
78
|
+
if (isLocalPreviewEnabled()) {
|
|
79
|
+
const record = previewDelegations.find((entry) => entry.state === "pending" && normalizedCode(entry.userCode ?? "") === normalizedCode(userCode));
|
|
80
|
+
if (!record) throw new Error("That code was not found, has expired, or was already decided.");
|
|
81
|
+
Object.assign(record, { state: "denied", userCode: null, deniedAt: new Date().toISOString() });
|
|
82
|
+
return { ...record };
|
|
61
83
|
}
|
|
62
|
-
const data = await runGraphql<{
|
|
63
|
-
return data.
|
|
84
|
+
const data = await runGraphql<{ denyCurationDelegation: CurationDelegationRecord }>(DENY_MUTATION, { userCode });
|
|
85
|
+
return data.denyCurationDelegation;
|
|
64
86
|
}
|
|
65
87
|
|
|
66
88
|
export async function revokeCurationDelegation(delegationId: string): Promise<CurationDelegationRecord> {
|
|
@@ -68,6 +90,7 @@ export async function revokeCurationDelegation(delegationId: string): Promise<Cu
|
|
|
68
90
|
const record = previewDelegations.find((entry) => entry.delegationId === delegationId);
|
|
69
91
|
if (!record) throw new Error("That delegation was not found.");
|
|
70
92
|
record.revokedAt = record.revokedAt ?? new Date().toISOString();
|
|
93
|
+
record.state = "revoked";
|
|
71
94
|
return { ...record };
|
|
72
95
|
}
|
|
73
96
|
const data = await runGraphql<{ revokeCurationDelegation: CurationDelegationRecord }>(REVOKE_MUTATION, { delegationId });
|
|
@@ -75,7 +98,7 @@ export async function revokeCurationDelegation(delegationId: string): Promise<Cu
|
|
|
75
98
|
}
|
|
76
99
|
|
|
77
100
|
export async function listCurationDelegations(): Promise<CurationDelegationRecord[]> {
|
|
78
|
-
if (isLocalPreviewEnabled()) return previewDelegations.map((entry) => ({ ...entry }))
|
|
101
|
+
if (isLocalPreviewEnabled()) return previewDelegations.map((entry) => ({ ...entry }));
|
|
79
102
|
const data = await runGraphql<{ listCurationDelegations: CurationDelegationRecord[] }>(LIST_QUERY, {});
|
|
80
103
|
return data.listCurationDelegations ?? [];
|
|
81
104
|
}
|
|
@@ -274,6 +274,14 @@ body:has(.newsroom-organizer)>[data-staging-banner]{flex-shrink:0}
|
|
|
274
274
|
.organizer-account-chooser li button small,.organizer-account-chooser-note{font-size:12px;color:var(--org-ink);margin:0}
|
|
275
275
|
.organizer-account-chooser-heading{margin:0;font:600 11px/1.4 Newsroom UI,system-ui,sans-serif;letter-spacing:.07em;text-transform:uppercase}
|
|
276
276
|
.organizer-account-chooser-link{color:var(--org-ink);text-decoration:underline;text-underline-offset:3px}
|
|
277
|
+
.organizer-account-create-start,.organizer-account-create button{background:none;border:1px solid var(--org-ink);border-radius:0;color:var(--org-ink);font:600 12px/1.4 Newsroom UI,system-ui,sans-serif;padding:4px 10px;cursor:pointer;text-underline-offset:3px}
|
|
278
|
+
.organizer-account-create-start:hover,.organizer-account-create button:hover{text-decoration:underline}
|
|
279
|
+
.organizer-account-create-start:focus-visible,.organizer-account-create button:focus-visible,.organizer-account-create input:focus-visible{outline:2px solid var(--org-ink);outline-offset:1px}
|
|
280
|
+
.organizer-account-create button:disabled{cursor:default;text-decoration:line-through}
|
|
281
|
+
.organizer-account-create{display:grid;gap:6px}
|
|
282
|
+
.organizer-account-create label{display:grid;gap:2px;font:600 11px/1.4 Newsroom UI,system-ui,sans-serif;letter-spacing:.07em;text-transform:uppercase}
|
|
283
|
+
.organizer-account-create input{background:transparent;border:0;border-bottom:2px solid var(--org-ink);border-radius:0;color:var(--org-ink);font:400 14px/1.4 Newsroom UI,system-ui,sans-serif;padding:4px 0;text-transform:none;letter-spacing:0}
|
|
284
|
+
.organizer-account-create span{display:flex;gap:8px}
|
|
277
285
|
[data-reference-cyclotron-status] .cyclotron-status[data-variant]{border-radius:0;background:transparent;border-top:1px solid var(--org-ink);border-bottom:1px solid var(--org-ink);padding-left:0;padding-right:0}
|
|
278
286
|
[data-reference-cyclotron-status] :is(.cyclotron-status-note,.cyclotron-status-definition,.cyclotron-status-header p,.cyclotron-status-override){color:var(--org-ink)}
|
|
279
287
|
[data-reference-cyclotron-status] .cyclotron-status[data-variant="strip"]>span+span::before{color:var(--org-ink)}
|
|
@@ -295,4 +303,17 @@ body:has(.newsroom-organizer)>[data-staging-banner]{flex-shrink:0}
|
|
|
295
303
|
.news-desk-columns--administration .news-desk-delegation-list li[data-delegation-live=true] .news-desk-delegation-state{font-weight:700;text-decoration:underline;text-underline-offset:4px}
|
|
296
304
|
.news-desk-columns--administration .news-desk-delegation-state{font:600 12px/1.4 Newsroom UI,system-ui,sans-serif;color:var(--administration-strong)}
|
|
297
305
|
.news-desk-columns--administration .news-desk-delegation-confirm{display:flex;flex-wrap:wrap;gap:8px;align-items:center}
|
|
306
|
+
.news-desk-columns--administration .news-desk-delegation-pending-list{list-style:none;margin:0;padding:0;display:grid}
|
|
307
|
+
.news-desk-columns--administration .news-desk-delegation-pending-list li{display:grid;grid-template-columns:minmax(0,1fr) auto auto;gap:12px 24px;align-items:center;padding:16px 0 16px 12px;border-top:1px solid var(--org-line);border-left:3px solid var(--org-ink)}
|
|
308
|
+
.news-desk-columns--administration .news-desk-delegation-pending-list li>div{display:grid;gap:2px}
|
|
309
|
+
.news-desk-columns--administration .news-desk-delegation-pending-list li>div>strong{font:700 18px/1.2 Newsroom Display,Georgia,serif}
|
|
310
|
+
.news-desk-columns--administration .news-desk-delegation-pending-list li>div>span{font:400 13px/1.4 Newsroom UI,system-ui,sans-serif}
|
|
311
|
+
.news-desk-columns--administration .news-desk-delegation-countdown{font-weight:600!important;font-variant-numeric:tabular-nums}
|
|
312
|
+
.news-desk-columns--administration .news-desk-delegation-code{font:700 34px/1.1 ui-monospace,Menlo,monospace;letter-spacing:.14em;color:var(--org-ink);padding:6px 0;border-top:2px solid var(--org-ink);border-bottom:2px solid var(--org-ink)}
|
|
313
|
+
.news-desk-columns--administration .news-desk-delegation-decision{display:flex;gap:8px}
|
|
314
|
+
.news-desk-columns--administration .news-desk-delegation-decision button{min-height:40px;padding:4px 18px;border:1px solid var(--org-ink);border-radius:0;background:transparent;color:var(--org-ink);font:700 12px/1.2 Newsroom UI,system-ui,sans-serif;letter-spacing:.07em;text-transform:uppercase;cursor:pointer}
|
|
315
|
+
.news-desk-columns--administration .news-desk-delegation-decision button:hover:not(:disabled){text-decoration:underline;text-underline-offset:4px}
|
|
316
|
+
.news-desk-columns--administration .news-desk-delegation-decision button:disabled{cursor:default;text-decoration:line-through}
|
|
317
|
+
.news-desk-columns--administration .news-desk-delegation-decision button:focus-visible{outline:2px solid var(--org-ink);outline-offset:2px}
|
|
318
|
+
@container (max-width:640px){.news-desk-columns--administration .news-desk-delegation-pending-list li{grid-template-columns:minmax(0,1fr)}.news-desk-columns--administration .news-desk-delegation-code{font-size:28px}}
|
|
298
319
|
@container (max-width:640px){.news-desk-columns--administration .news-desk-delegation-form{grid-template-columns:minmax(0,1fr)}.news-desk-columns--administration .news-desk-delegation-list li{grid-template-columns:minmax(0,1fr)}}
|
|
@@ -4,9 +4,15 @@ import { useEffect, useState } from "react";
|
|
|
4
4
|
import { Check } from "lucide-react";
|
|
5
5
|
import { isLocalPreviewEnabled } from "../lib/local-preview";
|
|
6
6
|
import { LOCAL_PREVIEW_TENANTS, getLocalPreviewTenantId, setLocalPreviewTenantId } from "../lib/local-preview-tenant";
|
|
7
|
-
import { loadProspectusAccountState, selectProspectusAccount, type ProspectusAccountState } from "../lib/prospectus-account-client";
|
|
7
|
+
import { createProspectusAccount, loadProspectusAccountState, selectProspectusAccount, type ProspectusAccountState } from "../lib/prospectus-account-client";
|
|
8
|
+
|
|
9
|
+
/** Local preview: add ?no-accounts=1 to the address to see a signed-in person with no publishing account. */
|
|
10
|
+
function previewHasNoAccounts(): boolean {
|
|
11
|
+
return typeof window !== "undefined" && new URLSearchParams(window.location.search).has("no-accounts");
|
|
12
|
+
}
|
|
8
13
|
|
|
9
14
|
function previewAccountState(): ProspectusAccountState {
|
|
15
|
+
if (previewHasNoAccounts()) return { status: "signedIn", publishingAccounts: [], selectedAccountId: null, cyclotronLabAccess: false };
|
|
10
16
|
return {
|
|
11
17
|
status: "signedIn",
|
|
12
18
|
publishingAccounts: LOCAL_PREVIEW_TENANTS.map(tenant => ({ accountId: tenant.accountId, name: tenant.name, role: tenant.role })),
|
|
@@ -50,6 +56,8 @@ export function ProspectusAccountChooser() {
|
|
|
50
56
|
const [state, setState] = useState<ProspectusAccountState>({ status: "unavailable" });
|
|
51
57
|
const [error, setError] = useState<string | null>(null);
|
|
52
58
|
const [pending, setPending] = useState<string | null>(null);
|
|
59
|
+
const [creating, setCreating] = useState(false);
|
|
60
|
+
const [newName, setNewName] = useState("");
|
|
53
61
|
useEffect(() => {
|
|
54
62
|
let cancelled = false;
|
|
55
63
|
const returnPath = `${window.location.pathname}${window.location.search}`;
|
|
@@ -76,6 +84,20 @@ export function ProspectusAccountChooser() {
|
|
|
76
84
|
if ("error" in result) { setError(result.error); return; }
|
|
77
85
|
window.location.reload();
|
|
78
86
|
};
|
|
87
|
+
const create = async () => {
|
|
88
|
+
if (pending) return;
|
|
89
|
+
setPending("create"); setError(null);
|
|
90
|
+
if (isLocalPreviewEnabled()) {
|
|
91
|
+
const name = newName.trim() || "New publishing account";
|
|
92
|
+
setState({ status: "signedIn", publishingAccounts: [{ accountId: "preview-created-account", name, role: "owner" }], selectedAccountId: "preview-created-account", cyclotronLabAccess: false });
|
|
93
|
+
setPending(null); setCreating(false);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
const result = await createProspectusAccount(newName);
|
|
97
|
+
setPending(null);
|
|
98
|
+
if ("error" in result) { setError(result.error); return; }
|
|
99
|
+
window.location.reload();
|
|
100
|
+
};
|
|
79
101
|
return <div className="organizer-account-chooser" data-prospectus-account-chooser="signed-in">
|
|
80
102
|
<p className="organizer-account-chooser-heading">Publishing account</p>
|
|
81
103
|
{state.publishingAccounts.length === 0
|
|
@@ -91,6 +113,17 @@ export function ProspectusAccountChooser() {
|
|
|
91
113
|
</li>;
|
|
92
114
|
})}
|
|
93
115
|
</ul>}
|
|
116
|
+
{state.publishingAccounts.length === 0 ? (
|
|
117
|
+
creating
|
|
118
|
+
? <form className="organizer-account-create" data-prospectus-create-account onSubmit={event => { event.preventDefault(); void create(); }}>
|
|
119
|
+
<label>Account name<input autoFocus disabled={pending !== null} maxLength={80} onChange={event => setNewName(event.target.value)} placeholder="Optional" type="text" value={newName} /></label>
|
|
120
|
+
<span>
|
|
121
|
+
<button data-prospectus-create-account-submit disabled={pending !== null} type="submit">{pending === "create" ? "Creating…" : "Create account"}</button>
|
|
122
|
+
<button disabled={pending !== null} onClick={() => setCreating(false)} type="button">Cancel</button>
|
|
123
|
+
</span>
|
|
124
|
+
</form>
|
|
125
|
+
: <button className="organizer-account-create-start" data-prospectus-create-account-start onClick={() => setCreating(true)} type="button">Create publishing account</button>
|
|
126
|
+
) : null}
|
|
94
127
|
{!state.selectedAccountId && state.publishingAccounts.length > 0 ? <p className="organizer-account-chooser-note">Choose the account you are working as.</p> : null}
|
|
95
128
|
{state.cyclotronLabAccess ? <p className="organizer-account-chooser-note" data-cyclotron-lab-access>Cyclotron lab access is on for you.</p> : null}
|
|
96
129
|
{error ? <p className="organizer-account-chooser-note" role="alert">{error}</p> : null}
|
|
@@ -43,11 +43,22 @@ The stack also creates the `<siteId>-papyrus-authoring` role. The CLI signs
|
|
|
43
43
|
* AppSync requests (SigV4) with credentials for that role (SSO, OIDC or any
|
|
44
44
|
* principal in the account that may assume it); no token is stored anywhere.
|
|
45
45
|
*
|
|
46
|
-
*
|
|
47
|
-
* until Amplify's GitHub App is
|
|
48
|
-
* (docs/site-hosting.md).
|
|
46
|
+
* Unless `repositoryAccessTokenSecretArn` is set, no repository and no access
|
|
47
|
+
* token are configured: the apps are manual-deploy until Amplify's GitHub App is
|
|
48
|
+
* connected once in the console (docs/site-hosting.md). When it is set, both apps
|
|
49
|
+
* are created against `repository` and read the GitHub token from that Secrets
|
|
50
|
+
* Manager secret through a CloudFormation dynamic reference, so the token never
|
|
51
|
+
* appears in site.json or the synthesized template. The Papyrus backend itself is created inside the CMS
|
|
49
52
|
* app by `ampx pipeline-deploy` from the production build.
|
|
50
53
|
*/
|
|
54
|
+
function repositoryConnectionProperties(config) {
|
|
55
|
+
if (config.repositoryAccessTokenSecretArn === undefined)
|
|
56
|
+
return {};
|
|
57
|
+
return {
|
|
58
|
+
repository: config.repository,
|
|
59
|
+
accessToken: `{{resolve:secretsmanager:${config.repositoryAccessTokenSecretArn}:SecretString}}`,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
51
62
|
export class AmplifyAppShellStack extends Stack {
|
|
52
63
|
cmsAppId;
|
|
53
64
|
readerAppId;
|
|
@@ -114,7 +125,9 @@ export class AmplifyAppShellStack extends Stack {
|
|
|
114
125
|
resources: PROSPECTUS_PAPYRUS_SITE_ROUTES.map((route) => `${prospectusAccountsApiStageArn}/${route}`),
|
|
115
126
|
}));
|
|
116
127
|
}
|
|
128
|
+
const repositoryConnection = repositoryConnectionProperties(config);
|
|
117
129
|
const cmsApp = new amplify.CfnApp(this, "App", {
|
|
130
|
+
...repositoryConnection,
|
|
118
131
|
name: cmsAppName,
|
|
119
132
|
description: `Papyrus newsroom CMS for ${config.siteId} (WEB_COMPUTE)`,
|
|
120
133
|
platform: "WEB_COMPUTE",
|
|
@@ -332,6 +345,7 @@ export class AmplifyAppShellStack extends Stack {
|
|
|
332
345
|
resources: ["arn:aws:s3:::amplify-*/media/*"],
|
|
333
346
|
}));
|
|
334
347
|
const readerApp = new amplify.CfnApp(this, "ReaderApp", {
|
|
348
|
+
...repositoryConnectionProperties(config),
|
|
335
349
|
name: readerAppName,
|
|
336
350
|
description: `Papyrus static reader for ${config.siteId} (WEB)`,
|
|
337
351
|
platform: "WEB",
|
|
@@ -46,11 +46,22 @@ The stack also creates the `<siteId>-papyrus-authoring` role. The CLI signs
|
|
|
46
46
|
* AppSync requests (SigV4) with credentials for that role (SSO, OIDC or any
|
|
47
47
|
* principal in the account that may assume it); no token is stored anywhere.
|
|
48
48
|
*
|
|
49
|
-
*
|
|
50
|
-
* until Amplify's GitHub App is
|
|
51
|
-
* (docs/site-hosting.md).
|
|
49
|
+
* Unless `repositoryAccessTokenSecretArn` is set, no repository and no access
|
|
50
|
+
* token are configured: the apps are manual-deploy until Amplify's GitHub App is
|
|
51
|
+
* connected once in the console (docs/site-hosting.md). When it is set, both apps
|
|
52
|
+
* are created against `repository` and read the GitHub token from that Secrets
|
|
53
|
+
* Manager secret through a CloudFormation dynamic reference, so the token never
|
|
54
|
+
* appears in site.json or the synthesized template. The Papyrus backend itself is created inside the CMS
|
|
52
55
|
* app by `ampx pipeline-deploy` from the production build.
|
|
53
56
|
*/
|
|
57
|
+
function repositoryConnectionProperties(config: AmplifyAppShellSiteConfig): { repository?: string; accessToken?: string } {
|
|
58
|
+
if (config.repositoryAccessTokenSecretArn === undefined) return {};
|
|
59
|
+
return {
|
|
60
|
+
repository: config.repository,
|
|
61
|
+
accessToken: `{{resolve:secretsmanager:${config.repositoryAccessTokenSecretArn}:SecretString}}`,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
54
65
|
export class AmplifyAppShellStack extends Stack {
|
|
55
66
|
public readonly cmsAppId?: string;
|
|
56
67
|
public readonly readerAppId?: string;
|
|
@@ -130,7 +141,10 @@ export class AmplifyAppShellStack extends Stack {
|
|
|
130
141
|
);
|
|
131
142
|
}
|
|
132
143
|
|
|
144
|
+
const repositoryConnection = repositoryConnectionProperties(config);
|
|
145
|
+
|
|
133
146
|
const cmsApp = new amplify.CfnApp(this, "App", {
|
|
147
|
+
...repositoryConnection,
|
|
134
148
|
name: cmsAppName,
|
|
135
149
|
description: `Papyrus newsroom CMS for ${config.siteId} (WEB_COMPUTE)`,
|
|
136
150
|
platform: "WEB_COMPUTE",
|
|
@@ -389,6 +403,7 @@ export class AmplifyAppShellStack extends Stack {
|
|
|
389
403
|
);
|
|
390
404
|
|
|
391
405
|
const readerApp = new amplify.CfnApp(this, "ReaderApp", {
|
|
406
|
+
...repositoryConnectionProperties(config),
|
|
392
407
|
name: readerAppName,
|
|
393
408
|
description: `Papyrus static reader for ${config.siteId} (WEB)`,
|
|
394
409
|
platform: "WEB",
|
package/infra/site-config.js
CHANGED
|
@@ -5,6 +5,7 @@ export function hasCmsApp(config) {
|
|
|
5
5
|
export const DEFAULT_CI_BRANCHES = ["main", "staging"];
|
|
6
6
|
const SITE_ID_PATTERN = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
|
|
7
7
|
const REPOSITORY_PATTERN = /^https:\/\/github\.com\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
|
|
8
|
+
const SECRETS_MANAGER_SECRET_ARN_PATTERN = /^arn:aws:secretsmanager:[a-z0-9-]+:\d{12}:secret:[A-Za-z0-9/_+=.@-]+$/;
|
|
8
9
|
const HOSTED_ZONE_ID_PATTERN = /^Z[A-Z0-9]+$/;
|
|
9
10
|
const HOST_NAME_PATTERN = /^([a-z0-9]([a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,}$/;
|
|
10
11
|
const DOMAIN_PREFIX_PATTERN = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/;
|
|
@@ -297,9 +298,12 @@ function validateOauthRedirectUrls(config) {
|
|
|
297
298
|
}
|
|
298
299
|
export function parseSiteConfig(raw) {
|
|
299
300
|
const record = requireObject(raw, "site");
|
|
300
|
-
rejectUnknownKeys(record, ["siteId", "repository", "brand", "frontend", "hostedZoneId", "stackName", "cms", "reader", "papyrusVersion", "storagePreviewPrefix", "github"], "site");
|
|
301
|
+
rejectUnknownKeys(record, ["siteId", "repository", "repositoryAccessTokenSecretArn", "brand", "frontend", "hostedZoneId", "stackName", "cms", "reader", "papyrusVersion", "storagePreviewPrefix", "github"], "site");
|
|
301
302
|
const siteId = requireString(record.siteId, "siteId", SITE_ID_PATTERN, "kebab-case");
|
|
302
303
|
const repository = requireString(record.repository, "repository", REPOSITORY_PATTERN, "https://github.com/<org>/<repo>");
|
|
304
|
+
const repositoryAccessTokenSecretArn = record.repositoryAccessTokenSecretArn === undefined
|
|
305
|
+
? undefined
|
|
306
|
+
: requireString(record.repositoryAccessTokenSecretArn, "repositoryAccessTokenSecretArn", SECRETS_MANAGER_SECRET_ARN_PATTERN, "Secrets Manager secret ARN");
|
|
303
307
|
const brand = requireString(record.brand, "brand");
|
|
304
308
|
const frontend = requireString(record.frontend, "frontend");
|
|
305
309
|
if (frontend !== "pretext" && frontend !== "markus-static") {
|
|
@@ -382,6 +386,7 @@ export function parseSiteConfig(raw) {
|
|
|
382
386
|
const config = {
|
|
383
387
|
siteId,
|
|
384
388
|
repository,
|
|
389
|
+
repositoryAccessTokenSecretArn,
|
|
385
390
|
brand,
|
|
386
391
|
frontend,
|
|
387
392
|
hostedZoneId,
|
package/infra/site-config.ts
CHANGED
|
@@ -47,6 +47,7 @@ export type CmsConfig = {
|
|
|
47
47
|
export type AmplifyAppShellSiteConfig = {
|
|
48
48
|
siteId: string;
|
|
49
49
|
repository: string;
|
|
50
|
+
repositoryAccessTokenSecretArn?: string;
|
|
50
51
|
brand: string;
|
|
51
52
|
frontend: "pretext" | "markus-static";
|
|
52
53
|
hostedZoneId?: string;
|
|
@@ -85,6 +86,7 @@ export const DEFAULT_CI_BRANCHES = ["main", "staging"];
|
|
|
85
86
|
|
|
86
87
|
const SITE_ID_PATTERN = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
|
|
87
88
|
const REPOSITORY_PATTERN = /^https:\/\/github\.com\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
|
|
89
|
+
const SECRETS_MANAGER_SECRET_ARN_PATTERN = /^arn:aws:secretsmanager:[a-z0-9-]+:\d{12}:secret:[A-Za-z0-9/_+=.@-]+$/;
|
|
88
90
|
const HOSTED_ZONE_ID_PATTERN = /^Z[A-Z0-9]+$/;
|
|
89
91
|
const HOST_NAME_PATTERN = /^([a-z0-9]([a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,}$/;
|
|
90
92
|
const DOMAIN_PREFIX_PATTERN = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/;
|
|
@@ -392,12 +394,15 @@ export function parseSiteConfig(raw: unknown): AmplifyAppShellSiteConfig {
|
|
|
392
394
|
const record = requireObject(raw, "site");
|
|
393
395
|
rejectUnknownKeys(
|
|
394
396
|
record,
|
|
395
|
-
["siteId", "repository", "brand", "frontend", "hostedZoneId", "stackName", "cms", "reader", "papyrusVersion", "storagePreviewPrefix", "github"],
|
|
397
|
+
["siteId", "repository", "repositoryAccessTokenSecretArn", "brand", "frontend", "hostedZoneId", "stackName", "cms", "reader", "papyrusVersion", "storagePreviewPrefix", "github"],
|
|
396
398
|
"site",
|
|
397
399
|
);
|
|
398
400
|
|
|
399
401
|
const siteId = requireString(record.siteId, "siteId", SITE_ID_PATTERN, "kebab-case");
|
|
400
402
|
const repository = requireString(record.repository, "repository", REPOSITORY_PATTERN, "https://github.com/<org>/<repo>");
|
|
403
|
+
const repositoryAccessTokenSecretArn = record.repositoryAccessTokenSecretArn === undefined
|
|
404
|
+
? undefined
|
|
405
|
+
: requireString(record.repositoryAccessTokenSecretArn, "repositoryAccessTokenSecretArn", SECRETS_MANAGER_SECRET_ARN_PATTERN, "Secrets Manager secret ARN");
|
|
401
406
|
const brand = requireString(record.brand, "brand");
|
|
402
407
|
const frontend = requireString(record.frontend, "frontend");
|
|
403
408
|
if (frontend !== "pretext" && frontend !== "markus-static") {
|
|
@@ -480,6 +485,7 @@ export function parseSiteConfig(raw: unknown): AmplifyAppShellSiteConfig {
|
|
|
480
485
|
const config: AmplifyAppShellSiteConfig = {
|
|
481
486
|
siteId,
|
|
482
487
|
repository,
|
|
488
|
+
repositoryAccessTokenSecretArn,
|
|
483
489
|
brand,
|
|
484
490
|
frontend,
|
|
485
491
|
hostedZoneId,
|