@anthusai/papyrus 5.33.2 → 5.35.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/amplify/auth/resource.js +16 -1
  2. package/amplify/auth/resource.ts +19 -1
  3. package/amplify/site-backend-config.ts +1 -1
  4. package/amplify/site-backend-names.js +4 -2
  5. package/amplify/site-backend-names.ts +4 -2
  6. package/amplify/site-backend.js +5 -2
  7. package/amplify/site-backend.ts +6 -2
  8. package/components/news-desk-client-provider.tsx +18 -18
  9. package/components/news-desk-taxonomy-client.ts +4 -7
  10. package/components/newsroom-access-gate.tsx +15 -1
  11. package/components/newsroom-article-editor.tsx +4 -2
  12. package/components/newsroom-articles-view.tsx +6 -6
  13. package/components/newsroom-board-view.tsx +8 -3
  14. package/components/newsroom-ops-overview.tsx +12 -100
  15. package/components/newsroom-ops-shell.tsx +2 -2
  16. package/components/newsroom-organizer.css +2 -0
  17. package/components/newsroom-overview-skeleton.tsx +25 -0
  18. package/components/notification-center.tsx +13 -2
  19. package/components/reader-auth-state.ts +1 -1
  20. package/components/topic-steering-workspace.tsx +1 -2
  21. package/infra/amplify-app-shell.js +4 -0
  22. package/infra/amplify-app-shell.ts +4 -0
  23. package/infra/cloud-email-config.js +32 -0
  24. package/infra/cloud-email-config.ts +39 -0
  25. package/infra/cloud-email.js +57 -0
  26. package/infra/cloud-email.test.js +69 -0
  27. package/infra/cloud-email.test.ts +66 -0
  28. package/infra/cloud-email.ts +60 -0
  29. package/infra/site-config.js +4 -2
  30. package/infra/site-config.ts +7 -2
  31. package/lib/news-desk-session.ts +12 -21
  32. package/lib/newsroom-organizer-nav.ts +2 -1
  33. package/package.json +2 -1
  34. package/routes.manifest.json +1 -1
@@ -1,6 +1,6 @@
1
1
  import { defineAuth, secret } from "@aws-amplify/backend";
2
2
  import { manageUserRole } from "../functions/manage-user-role/resource.js";
3
- import { CfnUserPoolDomain } from "aws-cdk-lib/aws-cognito";
3
+ import { CfnUserPoolDomain, CfnUserPoolIdentityProvider } from "aws-cdk-lib/aws-cognito";
4
4
  import { expandNewsroomRedirectUrls } from "./redirect-urls.js";
5
5
  // Default OAuth redirect URLs for the p.apyr.us app. Other publications pass
6
6
  // `auth.redirectUrls` to `defineSiteBackend` (or, for Papyrus's own backend,
@@ -35,6 +35,21 @@ export function authConfigFromEnv() {
35
35
  export function siteAuthRedirectUrls(config) {
36
36
  return config.redirectUrls ?? DEFAULT_AUTH_REDIRECT_URLS;
37
37
  }
38
+ /** Keep Cognito's federated email verification state in sync with Google's verified claim. */
39
+ export function applyGoogleEmailVerifiedMapping(authStack) {
40
+ const googleProviders = authStack.node
41
+ .findAll()
42
+ .filter((construct) => construct instanceof CfnUserPoolIdentityProvider && construct.providerName === "Google");
43
+ if (googleProviders.length !== 1) {
44
+ throw new Error(`Expected exactly one Google identity provider to map email_verified, found ${googleProviders.length}`);
45
+ }
46
+ const [provider] = googleProviders;
47
+ const mapping = provider.attributeMapping;
48
+ if (mapping !== undefined && (typeof mapping !== "object" || Array.isArray(mapping))) {
49
+ throw new Error("Expected Google identity provider attribute mappings to be a plain object");
50
+ }
51
+ provider.attributeMapping = { ...(mapping ?? {}), email_verified: "email_verified" };
52
+ }
38
53
  export function defineSiteAuth(config) {
39
54
  const authRedirectUrls = expandNewsroomRedirectUrls(siteAuthRedirectUrls(config), config.newsroomBasePath);
40
55
  const disableGoogleOAuth = config.disableGoogleOAuth === true;
@@ -1,7 +1,7 @@
1
1
  import { defineAuth, secret } from "@aws-amplify/backend";
2
2
  import { manageUserRole } from "../functions/manage-user-role/resource";
3
3
  import { Stack } from "aws-cdk-lib";
4
- import { CfnUserPoolDomain } from "aws-cdk-lib/aws-cognito";
4
+ import { CfnUserPoolDomain, CfnUserPoolIdentityProvider } from "aws-cdk-lib/aws-cognito";
5
5
  import type { PapyrusAuthConfig } from "../site-backend-config";
6
6
  import { expandNewsroomRedirectUrls } from "./redirect-urls";
7
7
 
@@ -41,6 +41,24 @@ export function siteAuthRedirectUrls(config: PapyrusAuthConfig): string[] {
41
41
  return config.redirectUrls ?? DEFAULT_AUTH_REDIRECT_URLS;
42
42
  }
43
43
 
44
+ /** Keep Cognito's federated email verification state in sync with Google's verified claim. */
45
+ export function applyGoogleEmailVerifiedMapping(authStack: Stack): void {
46
+ const googleProviders = authStack.node
47
+ .findAll()
48
+ .filter((construct): construct is CfnUserPoolIdentityProvider =>
49
+ construct instanceof CfnUserPoolIdentityProvider && construct.providerName === "Google");
50
+ if (googleProviders.length !== 1) {
51
+ throw new Error(`Expected exactly one Google identity provider to map email_verified, found ${googleProviders.length}`);
52
+ }
53
+
54
+ const [provider] = googleProviders;
55
+ const mapping = provider.attributeMapping;
56
+ if (mapping !== undefined && (typeof mapping !== "object" || Array.isArray(mapping))) {
57
+ throw new Error("Expected Google identity provider attribute mappings to be a plain object");
58
+ }
59
+ provider.attributeMapping = { ...(mapping ?? {}), email_verified: "email_verified" };
60
+ }
61
+
44
62
  export function defineSiteAuth(config: PapyrusAuthConfig) {
45
63
  const authRedirectUrls = expandNewsroomRedirectUrls(
46
64
  siteAuthRedirectUrls(config),
@@ -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 site creates
72
- * its own brand-named rule set and never activates it from CloudFormation.
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 site creates
113
- * its own brand-named rule set and never activates it from CloudFormation.
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)
@@ -18,7 +18,7 @@ import { assertValidPublicationConfig } from "./site-backend-config.js";
18
18
  import { revalidateSecretReadStatement } from "./revalidate-secret-policy.js";
19
19
  import { revalidateDistributionEnvironment, revalidateInvalidationStatement, validateRevalidateDistributionIds, } from "./revalidate-invalidation-settings.js";
20
20
  import { addInvalidationPathAlarm } from "./publication/invalidation-alarm.js";
21
- import { applyCognitoDomainPrefix, authConfigFromEnv, defineSiteAuth, siteAuthRedirectUrls } from "./auth/resource.js";
21
+ import { applyCognitoDomainPrefix, applyGoogleEmailVerifiedMapping, authConfigFromEnv, defineSiteAuth, siteAuthRedirectUrls } from "./auth/resource.js";
22
22
  import { PROSPECTUS_SIGN_IN_CALLBACK_PATH, addProspectusGoogleOnlyClient } from "./auth/prospectus-google-client.js";
23
23
  import { defineSiteData } from "./data/resource.js";
24
24
  import { editorialChecks } from "./functions/editorial-checks/resource.js";
@@ -116,6 +116,9 @@ export function defineSiteBackend(papyrusSite) {
116
116
  slackDelivery,
117
117
  storage,
118
118
  });
119
+ if (authConfig.disableGoogleOAuth !== true) {
120
+ applyGoogleEmailVerifiedMapping(backend.auth.stack);
121
+ }
119
122
  orderModelStacksInWaves(backend.data.resources.graphqlApi.node.scope, Object.keys(backend.data.resources.tables), site.resolverWaveSize ?? DEFAULT_RESOLVER_WAVE_SIZE);
120
123
  const requestedCognitoDomainPrefix = (authConfig.cognitoDomainPrefix ?? "").trim();
121
124
  if (authConfig.applyCognitoDomainPrefix === true
@@ -469,7 +472,7 @@ export function defineSiteBackend(papyrusSite) {
469
472
  resources: ["*"],
470
473
  }));
471
474
  const inboundEventStack = Stack.of(receiveLambda);
472
- // SES allows one active receipt rule set per region, so a non-legacy site
475
+ // SES allows one active receipt rule set per account and region, so a non-legacy site
473
476
  // creates its own brand-named rule set and never activates it here.
474
477
  const inboundSesPlan = planInboundEmailSes(siteBackendIdentity, enableInboundEmail, site.inboundEmailSes);
475
478
  if (inboundSesPlan.createReceiptRules) {
@@ -25,7 +25,7 @@ import {
25
25
  validateRevalidateDistributionIds,
26
26
  } from "./revalidate-invalidation-settings";
27
27
  import { addInvalidationPathAlarm } from "./publication/invalidation-alarm";
28
- import { applyCognitoDomainPrefix, authConfigFromEnv, defineSiteAuth, siteAuthRedirectUrls } from "./auth/resource";
28
+ import { applyCognitoDomainPrefix, applyGoogleEmailVerifiedMapping, authConfigFromEnv, defineSiteAuth, siteAuthRedirectUrls } from "./auth/resource";
29
29
  import { PROSPECTUS_SIGN_IN_CALLBACK_PATH, addProspectusGoogleOnlyClient } from "./auth/prospectus-google-client";
30
30
  import { defineSiteData } from "./data/resource";
31
31
  import { editorialChecks } from "./functions/editorial-checks/resource";
@@ -138,6 +138,10 @@ export function defineSiteBackend(papyrusSite: PapyrusSite) {
138
138
  storage,
139
139
  });
140
140
 
141
+ if (authConfig.disableGoogleOAuth !== true) {
142
+ applyGoogleEmailVerifiedMapping(backend.auth.stack);
143
+ }
144
+
141
145
  orderModelStacksInWaves(
142
146
  backend.data.resources.graphqlApi.node.scope as Construct,
143
147
  Object.keys(backend.data.resources.tables),
@@ -601,7 +605,7 @@ export function defineSiteBackend(papyrusSite: PapyrusSite) {
601
605
 
602
606
  const inboundEventStack = Stack.of(receiveLambda);
603
607
 
604
- // SES allows one active receipt rule set per region, so a non-legacy site
608
+ // SES allows one active receipt rule set per account and region, so a non-legacy site
605
609
  // creates its own brand-named rule set and never activates it here.
606
610
  const inboundSesPlan = planInboundEmailSes(siteBackendIdentity, enableInboundEmail, site.inboundEmailSes);
607
611
  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
- beginAuthenticatedDeskHydration,
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) => resolveDashboardFailure(current, access.auth, access.error));
99
+ setShell((current) => resolveAccessFailure(current, access.error));
98
100
  return;
99
101
  }
100
102
 
101
- setShell((current) => beginAuthenticatedDeskHydration(current, access.auth, { canManageUsers: access.isAdmin }));
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) => resolveDashboardFailure(
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) => resolveDashboardFailure(
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) => resolveDashboardFailure(
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
- const snapshot = await loadReaderSessionSnapshot();
836
- if (!snapshot.hasSession || snapshot.auth.status === "signedOut") {
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 && isUnauthenticatedError(summaryError)) {
887
- throw summaryError;
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 className="flex items-center gap-2 text-sm text-muted-foreground">
517
- <Spinner /> Loading article…
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">{loading ? "Loading records…" : `${issues.length} records loaded.`}</span>
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
- <IssueCollection issues={issues} config={config} query={{ type: subject === "desk" ? issueTypeFromFilter(typeFilter) : boardType }} view={view}
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={loading ? "Loading records…" : error ? "Records could not be loaded." : "No records in this selection."} />
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, MessageRecord, NewsroomSectionRecord, ReferenceRecord } from "../lib/category-repository";
19
- import { getNewsroomNavHref, NEWSROOM_OPS_NAV, type NewsroomNavTabId } from "../lib/newsroom-nav";
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
- <NotificationCenter
132
- actorLabel={actorLabel}
133
- decisions={decisions}
134
- demo={demo}
135
- humanCuratedReferenceLineageIds={humanCuratedReferenceLineageIds}
136
- references={canonicalReferences}
137
- />
138
- <section aria-label="Destinations" className="space-y-3">
139
- <h2 className="text-sm font-medium text-muted-foreground">Go to</h2>
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
- Nothing needs attention right now. Check References for new intake or open Assignments from the destinations above.
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
+ }