@anthusai/papyrus 1.14.0 → 1.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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
- DEFAULT_DELEGATION_HOURS,
8
- MAXIMUM_DELEGATION_HOURS,
9
- MINIMUM_DELEGATION_HOURS,
10
- buildDelegatedCommandLine,
11
- delegationExpiryText,
5
+ delegationStateText,
12
6
  describeDelegationError,
13
- validateDelegationInput,
7
+ isPendingDelegation,
8
+ pendingCountdownText,
9
+ requestedHoursText,
14
10
  type CurationDelegationRecord,
15
11
  } from "../lib/curation-delegation-helpers";
16
- import { createCurationDelegation, listCurationDelegations, revokeCurationDelegation, type CreatedCurationDelegation } from "./curation-delegation-client";
12
+ import { approveCurationDelegation, denyCurationDelegation, listCurationDelegations, revokeCurationDelegation } from "./curation-delegation-client";
17
13
 
18
- type IssuedToken = { agentLabel: string; token: string };
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 [agentLabelText, setAgentLabelText] = useState("");
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 timer = window.setInterval(() => setNowMilliseconds(Date.now()), 30_000);
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(timer);
54
- setIssued(null);
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 handleCreate() {
68
- const validation = validateDelegationInput(agentLabelText, hoursText);
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
- const created: CreatedCurationDelegation = await createCurationDelegation(validation.agentLabel, validation.hours);
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
- setBusy(false);
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
- async function copyText(kind: "token" | "command", text: string) {
104
- try {
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>Agent delegations</h3>
120
- <span>Let one agent approve or reject References for you, for a short time</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-note">
124
- A delegation is a token you hand to one named agent. While it lasts, that agent&apos;s approvals and rejections are recorded under your name and marked as relayed. You can revoke it at any moment.
125
- </p>
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
- The agent must pass your actual words, in quotes, as the instruction on every approval or rejection. Papyrus keeps those words with the record. An agent that paraphrases or invents them is not following this delegation.
112
+ While a delegation lasts, that agent&apos;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>Granted by you, newest first</span>
120
+ <span>Decided requests, newest first</span>
169
121
  </div>
170
122
  </div>
171
- {delegations.length === 0 ? (
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
- {delegations.map((entry) => {
176
- const stateText = delegationExpiryText(entry.expiresAt, entry.revokedAt, nowMilliseconds);
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
- export type CreatedCurationDelegation = CurationDelegationRecord & { token: string };
10
+ const DELEGATION_FIELDS = "delegationId agentLabel grantorLabel expiresAt revokedAt state userCode requestedHours pendingExpiresAt approvedAt deniedAt";
11
11
 
12
- const CREATE_MUTATION = `
13
- mutation CreateCurationDelegation($agentLabel: String!, $hours: Int) {
14
- createCurationDelegation(agentLabel: $agentLabel, hours: $hours) {
15
- delegationId token agentLabel grantorLabel expiresAt revokedAt
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
- delegationId agentLabel grantorLabel expiresAt revokedAt
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
- delegationId agentLabel grantorLabel expiresAt revokedAt
35
+ ${DELEGATION_FIELDS}
32
36
  }
33
37
  }
34
38
  `;
35
39
 
36
- const previewDelegations: CurationDelegationRecord[] = [];
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 createCurationDelegation(agentLabel: string, hours: number): Promise<CreatedCurationDelegation> {
65
+ export async function approveCurationDelegation(userCode: string): Promise<CurationDelegationRecord> {
50
66
  if (isLocalPreviewEnabled()) {
51
- const delegationId = `curation-delegation-preview-${previewDelegations.length + 1}`;
52
- const record: CurationDelegationRecord = {
53
- delegationId,
54
- agentLabel,
55
- grantorLabel: "ryan@example.com",
56
- expiresAt: new Date(Date.now() + hours * 3_600_000).toISOString(),
57
- revokedAt: null,
58
- };
59
- previewDelegations.push(record);
60
- return { ...record, token: `${delegationId}~preview-token-not-valid-anywhere` };
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<{ createCurationDelegation: CreatedCurationDelegation }>(CREATE_MUTATION, { agentLabel, hours });
63
- return data.createCurationDelegation;
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 })).reverse();
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}
@@ -1,6 +1,4 @@
1
- export const DEFAULT_DELEGATION_HOURS = 8;
2
- export const MINIMUM_DELEGATION_HOURS = 1;
3
- export const MAXIMUM_DELEGATION_HOURS = 72;
1
+ export type CurationDelegationState = "pending" | "approved" | "denied" | "expired" | "revoked";
4
2
 
5
3
  export type CurationDelegationRecord = {
6
4
  delegationId: string;
@@ -8,25 +6,15 @@ export type CurationDelegationRecord = {
8
6
  grantorLabel: string | null;
9
7
  expiresAt: string;
10
8
  revokedAt: string | null;
9
+ state?: CurationDelegationState | null;
10
+ /** XXXX-XXXX, present only while the request is pending. */
11
+ userCode?: string | null;
12
+ requestedHours?: number | null;
13
+ pendingExpiresAt?: string | null;
14
+ approvedAt?: string | null;
15
+ deniedAt?: string | null;
11
16
  };
12
17
 
13
- export type DelegationInputValidation =
14
- | { ok: true; agentLabel: string; hours: number }
15
- | { ok: false; message: string };
16
-
17
- export function validateDelegationInput(agentLabelText: string, hoursText: string): DelegationInputValidation {
18
- const agentLabel = agentLabelText.trim();
19
- if (!agentLabel) return { ok: false, message: "Enter the name of the agent you are trusting." };
20
- if (/\s/.test(agentLabel)) return { ok: false, message: "The agent name cannot contain spaces. Use the name the agent passes as --agent." };
21
- const trimmedHours = hoursText.trim();
22
- if (!/^\d+$/.test(trimmedHours)) return { ok: false, message: `Enter a whole number of hours from ${MINIMUM_DELEGATION_HOURS} to ${MAXIMUM_DELEGATION_HOURS}.` };
23
- const hours = Number(trimmedHours);
24
- if (hours < MINIMUM_DELEGATION_HOURS || hours > MAXIMUM_DELEGATION_HOURS) {
25
- return { ok: false, message: `A delegation lasts from ${MINIMUM_DELEGATION_HOURS} to ${MAXIMUM_DELEGATION_HOURS} hours.` };
26
- }
27
- return { ok: true, agentLabel, hours };
28
- }
29
-
30
18
  export function delegationExpiryText(expiresAt: string, revokedAt: string | null, nowMilliseconds: number): string {
31
19
  if (revokedAt) return "Revoked";
32
20
  const remaining = Date.parse(expiresAt) - nowMilliseconds;
@@ -41,11 +29,6 @@ export function isDelegationActive(record: CurationDelegationRecord, nowMillisec
41
29
  return !record.revokedAt && Date.parse(record.expiresAt) > nowMilliseconds;
42
30
  }
43
31
 
44
- export function buildDelegatedCommandLine(input: { email: string; agentLabel: string; token: string; action?: "approve" | "reject" }): string {
45
- const email = input.email.trim() || "<your-email>";
46
- return `papyrus references ${input.action ?? "approve"} <reference-id> --on-behalf-of ${email} --agent ${input.agentLabel} --instruction "<the person's own words>" --delegation-token ${input.token}`;
47
- }
48
-
49
32
  export function describeDelegationError(error: unknown): string {
50
33
  const raw = error instanceof Error ? error.message : typeof error === "string" ? error : "";
51
34
  const graphql = (error as { errors?: Array<{ message?: string }> } | null)?.errors?.[0]?.message ?? "";
@@ -57,3 +40,30 @@ export function describeDelegationError(error: unknown): string {
57
40
  const detail = (graphql || raw).trim();
58
41
  return detail ? `The server refused the request: ${detail}` : "Something went wrong. Nothing was changed.";
59
42
  }
43
+
44
+ /** A pending request that an agent started and nobody has approved, denied or let lapse. */
45
+ export function isPendingDelegation(record: CurationDelegationRecord, nowMilliseconds: number): boolean {
46
+ return record.state === "pending" && Boolean(record.userCode) && Date.parse(record.pendingExpiresAt ?? record.expiresAt) > nowMilliseconds;
47
+ }
48
+
49
+ /** "9:41 left" for a request that is still open, "Expired" once its window has passed. */
50
+ export function pendingCountdownText(pendingExpiresAt: string | null | undefined, nowMilliseconds: number): string {
51
+ const remaining = Date.parse(pendingExpiresAt ?? "") - nowMilliseconds;
52
+ if (!Number.isFinite(remaining) || remaining <= 0) return "Expired";
53
+ const totalSeconds = Math.ceil(remaining / 1000);
54
+ const minutes = Math.floor(totalSeconds / 60);
55
+ const seconds = totalSeconds % 60;
56
+ return `${minutes}:${String(seconds).padStart(2, "0")} left`;
57
+ }
58
+
59
+ /** The state of a decided or lapsed delegation in words an editor reads in the list. */
60
+ export function delegationStateText(record: CurationDelegationRecord, nowMilliseconds: number): string {
61
+ if (record.state === "denied") return "Denied";
62
+ if (record.state === "pending") return pendingCountdownText(record.pendingExpiresAt ?? record.expiresAt, nowMilliseconds) === "Expired" ? "Request expired" : "Waiting for a decision";
63
+ return delegationExpiryText(record.expiresAt, record.revokedAt, nowMilliseconds);
64
+ }
65
+
66
+ export function requestedHoursText(hours: number | null | undefined): string {
67
+ if (!hours || hours <= 0) return "Duration not stated";
68
+ return `Asks for ${hours} ${hours === 1 ? "hour" : "hours"}`;
69
+ }
@@ -18,6 +18,7 @@ type FetchImplementation = (input: string, init?: RequestInit) => Promise<Respon
18
18
 
19
19
  const SESSION_PATH = "/api/auth/prospectus/session";
20
20
  const SELECTION_PATH = "/api/auth/prospectus/selection";
21
+ const ACCOUNTS_PATH = "/api/auth/prospectus/accounts";
21
22
  const SIGN_IN_PATH = "/api/auth/prospectus/sign-in";
22
23
 
23
24
  export function prospectusSignInUrl(returnPath: string): string {
@@ -62,3 +63,28 @@ export async function selectProspectusAccount(
62
63
  if (!response.ok || !body?.selectedAccountId) return { error: body?.message ?? "That account could not be selected." };
63
64
  return { selectedAccountId: body.selectedAccountId, role: body.role ?? "" };
64
65
  }
66
+
67
+ /**
68
+ * Create a publishing account for the signed-in person and select it. Each call carries a fresh idempotency key,
69
+ * so a retried request after a dropped response cannot create a second account.
70
+ */
71
+ export async function createProspectusAccount(
72
+ name: string,
73
+ fetchImplementation: FetchImplementation = fetch,
74
+ idempotencyKey: string = crypto.randomUUID(),
75
+ ): Promise<{ selectedAccountId: string; role: string } | { error: string }> {
76
+ let response: Response;
77
+ try {
78
+ response = await fetchImplementation(ACCOUNTS_PATH, {
79
+ method: "POST",
80
+ credentials: "same-origin",
81
+ headers: { "content-type": "application/json" },
82
+ body: JSON.stringify({ name: name.trim() || undefined, idempotencyKey }),
83
+ });
84
+ } catch {
85
+ return { error: "The publishing account could not be created. Check your connection and try again." };
86
+ }
87
+ const body = await response.json().catch(() => null) as { selectedAccountId?: string; role?: string; message?: string } | null;
88
+ if (!response.ok || !body?.selectedAccountId) return { error: body?.message ?? "The publishing account could not be created." };
89
+ return { selectedAccountId: body.selectedAccountId, role: body.role ?? "" };
90
+ }
@@ -282,3 +282,48 @@ export async function handleProspectusAccountSelection(request: NextRequest, opt
282
282
  return signInFailedResponse(503, "prospectus_unavailable", "Prospectus could not be reached.");
283
283
  }
284
284
  }
285
+
286
+ const MAXIMUM_PUBLISHING_ACCOUNT_NAME_LENGTH = 150;
287
+ const DEFAULT_PUBLISHING_ACCOUNT_NAME = "Publishing account";
288
+ const IDEMPOTENCY_KEY_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
289
+
290
+ export async function handleProspectusPublishingAccountCreation(request: NextRequest, options: ProspectusSignInRouteOptions = {}): Promise<NextResponse> {
291
+ const runtime = await signInRuntimeFor(request, options);
292
+ if (!runtime) return signInUnavailableResponse();
293
+ if (request.headers.get("origin") !== request.nextUrl.origin) {
294
+ return signInFailedResponse(403, "cross_origin_creation", "Create an account from this site.");
295
+ }
296
+ const nowInSeconds = currentTimeInSeconds(options);
297
+ const session = signedInProspectusSession(request, runtime.sealingSecret, nowInSeconds);
298
+ if (!session) return signInFailedResponse(401, "signed_out", "Sign in first.");
299
+ const body = await request.json().catch(() => null) as { name?: unknown; idempotencyKey?: unknown } | null;
300
+ const requestedName = typeof body?.name === "string" ? body.name.trim() : "";
301
+ const accountName = requestedName || DEFAULT_PUBLISHING_ACCOUNT_NAME;
302
+ if (accountName.length > MAXIMUM_PUBLISHING_ACCOUNT_NAME_LENGTH) {
303
+ return signInFailedResponse(400, "account_name_too_long", "Name the account in 150 characters or fewer.");
304
+ }
305
+ const idempotencyKey = typeof body?.idempotencyKey === "string" ? body.idempotencyKey : "";
306
+ if (!IDEMPOTENCY_KEY_PATTERN.test(idempotencyKey)) {
307
+ return signInFailedResponse(400, "idempotency_key_required", "Send a UUID idempotency key.");
308
+ }
309
+ try {
310
+ const created = await runtime.accounts.createAccount(session.accessToken, accountName, idempotencyKey);
311
+ const selection = await freshlyAuthorizedPublishingSelection(runtime, session, created.account.id, siteProductFor(options));
312
+ if (!selection) return signInFailedResponse(403, "not_a_publishing_account", "The new account does not grant publishing access on this site.");
313
+ const response = NextResponse.json(
314
+ { selectedAccountId: selection.accountId, role: selection.role, publicationCapabilities: selection.publicationCapabilities },
315
+ { status: 201, headers: NO_STORE_HEADERS },
316
+ );
317
+ const selectedSession: ProspectusSignedInSession = { ...session, selectedAccountId: selection.accountId };
318
+ response.cookies.set(
319
+ PROSPECTUS_SESSION_COOKIE,
320
+ sealProspectusCookie(runtime.sealingSecret, PROSPECTUS_SESSION_COOKIE, selectedSession, session.accessTokenExpiresAt),
321
+ sealedCookieAttributes(session.accessTokenExpiresAt, nowInSeconds),
322
+ );
323
+ return response;
324
+ } catch (error) {
325
+ if (error instanceof ProspectusAccountsError) return signInFailedResponse(error.status, error.code, error.message);
326
+ console.error(`Prospectus account creation failed (${error instanceof Error ? error.name : "error"}).`);
327
+ return signInFailedResponse(503, "prospectus_unavailable", "Prospectus could not be reached.");
328
+ }
329
+ }