Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions client/components/SuperAdminTag/SuperAdminTag.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import React from 'react';

import { Tag } from '@blueprintjs/core';

import { usePageContext } from 'utils/hooks';

type Props = {
className?: string;
};

/**
* Marks a setting that is only visible or editable to superadmins, so it stays
* obvious which options regular community admins can't see or use yet. Place
* next to the label of any superadmin-gated field or section. Renders nothing
* for non-superadmins, so it's safe on sections that some admins can also see
* (e.g. a configured Underlay integration).
*/
const SuperAdminTag = (props: Props) => {
const { loginData } = usePageContext();
if (!loginData.isSuperAdmin) {
return null;
}
return (
<Tag className={props.className} minimal intent="danger" icon="crown">
Superadmin only
</Tag>
);
};

export default SuperAdminTag;
1 change: 1 addition & 0 deletions client/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ export { default as SliderInput } from './SliderInput/SliderInput';
export { default as SpamStatusMenu } from './SpamStatusMenu';
export { default as SubmissionEmail } from './SubmissionEmail/SubmissionEmail';
export { default as SubscriptionButton } from './SubscriptionButton/SubscriptionButton';
export { default as SuperAdminTag } from './SuperAdminTag/SuperAdminTag';
export { default as TabToShow } from './TabToShow/TabToShow';
export { default as Thread } from './Thread/Thread';
export { default as ThreadInput } from './ThreadInput/ThreadInput';
Expand Down
101 changes: 101 additions & 0 deletions client/containers/DashboardSettings/CommunitySettings/CmsSettings.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import type { Callback, Community } from 'types';

import React from 'react';

import { Callout, Switch } from '@blueprintjs/core';

import { InputField, SettingsSection, SuperAdminTag } from 'components';
import { usePageContext } from 'utils/hooks';

type Props = {
communityData: Community;
updateCommunityData: Callback<Partial<Community>>;
};

const CmsSettings = (props: Props) => {
const { communityData, updateCommunityData } = props;
const { cmsMode, canonicalBaseUrl, canonicalPubUrlTemplate } = communityData;
const {
scopeData: {
activePermissions: { isSuperAdmin },
},
} = usePageContext();
if (!isSuperAdmin) {
return null;
}

return (
<SettingsSection
id="cms-mode"
title={
<>
CMS mode <SuperAdminTag />
</>
}
description={
Comment on lines +28 to +35
<>
In CMS mode, PubPub is used only for writing and editing: your community is
visible to members alone, and everyone else sees a not-found page with a sign-in
prompt. If a canonical URL is set, citations, exports, deposits, and connections
from other communities will point to the external site where your content is
published (for example, a site fed by your Underlay collection).
</>
}
>
<Switch
checked={!!cmsMode}
label="Enable CMS mode"
onChange={(evt) => {
updateCommunityData({ cmsMode: (evt.target as HTMLInputElement).checked });
}}
/>
{cmsMode && (
<Callout intent="warning" icon="warning-sign">
Your community is only visible to members. Public visitors will see a
&ldquo;community not found&rdquo; page with an option to log in.
</Callout>
)}
<InputField
label={
<>
Canonical URL <SuperAdminTag />
</>
}
helperText="The external site where this community's content is published, e.g. https://journal.example.org. Canonical tags, citations, exports, and new Crossref deposits will point to it."
type="text"
placeholder="https://journal.example.org"
value={canonicalBaseUrl || ''}
onChange={(evt) => {
updateCommunityData({
canonicalBaseUrl: evt.target.value.trim() || null,
});
}}
/>
<InputField
label={
<>
Pub URL template <SuperAdminTag />
</>
}
helperText={
<>
Where a pub lives on the external site, with <code>{'{slug}'}</code>{' '}
standing in for the pub slug, e.g.{' '}
<code>{'https://journal.example.org/articles/{slug}'}</code>. Leave empty to
use the canonical URL + <code>/pub/{'{slug}'}</code>.
</>
}
type="text"
placeholder="https://journal.example.org/articles/{slug}"
value={canonicalPubUrlTemplate || ''}
onChange={(evt) => {
updateCommunityData({
canonicalPubUrlTemplate: evt.target.value.trim() || null,
});
}}
/>
</SettingsSection>
);
};

export default CmsSettings;
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { usePageContext, usePendingChanges } from 'utils/hooks';
import DashboardSettingsFrame, { type Subtab } from '../DashboardSettingsFrame';
import AnalyticsSettings from './AnalyticsSettings';
import BasicSettings from './BasicSettings';
import CmsSettings from './CmsSettings';
import CommunityAdminSettings from './CommunityAdminSettings';
import CommunityOrCollectionLevelPubSettings from './CommunityOrCollectionLevelPubSettings';
import FooterSettings from './FooterSettings';
Expand Down Expand Up @@ -198,6 +199,22 @@ const CommunitySettings = (props: Props) => {
} as const,
]
: ([] as Subtab[])),
...(pageContext.scopeData.activePermissions.canAdminCommunity ||
pageContext.scopeData.activePermissions.isSuperAdmin
? [
{
id: 'cms-settings',
title: 'CMS',
icon: 'exchange',
sections: [
<CmsSettings
communityData={communityData}
updateCommunityData={updateCommunityData}
/>,
],
} as const,
]
: ([] as Subtab[])),
].filter((x): x is Subtab => Boolean(x)) satisfies Subtab[];

return (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import {
} from '@blueprintjs/core';

import { apiFetch } from 'client/utils/apiFetch';
import { InputField, SettingsSection } from 'components';
import { InputField, SettingsSection, SuperAdminTag } from 'components';

type Props = {
communityData: Community;
Expand Down Expand Up @@ -460,7 +460,18 @@ const UnderlaySettings = (_props: Props) => {
};

if (loading) {
return <SettingsSection title="Push to Underlay">Loading…</SettingsSection>;
return (
<SettingsSection
id="push-to-underlay"
title={
<>
Push to Underlay <SuperAdminTag />
</>
}
>
Loading…
</SettingsSection>
);
}

const isConfigured = Boolean(org && collection && hasKey);
Expand Down Expand Up @@ -497,7 +508,14 @@ const UnderlaySettings = (_props: Props) => {
: null;

return (
<SettingsSection title="Push to Underlay">
<SettingsSection
id="push-to-underlay"
title={
<>
Push to Underlay <SuperAdminTag />
</>
}
>
<p className={Classes.TEXT_MUTED}>
Push this community&rsquo;s releases and metadata to an Underlay collection. Only
new or changed content is transferred.
Expand Down
21 changes: 21 additions & 0 deletions server/community/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,27 @@ export class Community extends Model<
@Column(DataType.TEXT)
declare kfOrgId: string | null;

/**
* CMS mode: the community is only visible to members;
* public visitors are shown a not-found page with a sign-in prompt.
*/
@Default(false)
@AllowNull(false)
@Column(DataType.BOOLEAN)
declare cmsMode: CreationOptional<boolean>;

/** External canonical home for this community's content, e.g. https://journal.example.org */
@Column(DataType.TEXT)
declare canonicalBaseUrl: string | null;

/**
* Template for canonical pub URLs on the external site, with {slug} as the
* pub slug, e.g. https://journal.example.org/articles/{slug}. When unset,
* canonicalBaseUrl + /pub/{slug} is used.
*/
@Column(DataType.TEXT)
declare canonicalPubUrlTemplate: string | null;

@BelongsTo(() => CommunityTemplate, {
as: 'template',
foreignKey: 'templateId',
Expand Down
16 changes: 9 additions & 7 deletions server/community/permissions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,17 +71,19 @@ export const getPermissions = async ({

const canUpdate = scopeData.activePermissions.canManage;
const canAdmin = scopeData.activePermissions.canAdmin;
const canExportCommunity =
scopeData.activePermissions.canAdminCommunity || scopeData.activePermissions.isSuperAdmin;
const isSuperAdmin = scopeData.activePermissions.isSuperAdmin;
const canExportCommunity = scopeData.activePermissions.canAdminCommunity || isSuperAdmin;

// only admins can edit analytics settings
const editPropsWithAnalytics = canAdmin
? ([...editProps, 'analyticsSettings'] as const)
: editProps;
// only admins can edit analytics settings and toggle CMS mode
const adminEditProps = canAdmin ? (['analyticsSettings', 'cmsMode'] as const) : ([] as const);
// only superadmins can point a community at an external canonical URL
const superAdminEditProps = isSuperAdmin
? (['canonicalBaseUrl', 'canonicalPubUrlTemplate'] as const)
: ([] as const);

return {
create: true,
update: canUpdate ? editPropsWithAnalytics : false,
update: canUpdate ? [...editProps, ...adminEditProps, ...superAdminEditProps] : false,
admin: canAdmin,
communityExport: canExportCommunity,
};
Expand Down
12 changes: 11 additions & 1 deletion server/doi/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,17 @@ export const findPub = (pubId) =>
const findCommunity = (communityId) =>
Community.findOne({
where: { id: communityId },
attributes: ['id', 'title', 'issn', 'domain', 'subdomain', 'citeAs', 'publishAs'],
attributes: [
'id',
'title',
'issn',
'domain',
'subdomain',
'citeAs',
'publishAs',
'canonicalBaseUrl',
'canonicalPubUrlTemplate',
],
});

export const persistCrossrefDepositRecord = async (ids, depositJson) => {
Expand Down
18 changes: 17 additions & 1 deletion server/errorPages/communityNotFound.html
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,21 @@
height: 150px;
opacity: 0.5;
}

.login-button {
display: inline-block;
margin-top: 16px;
padding: 8px 20px;
border-radius: 3px;
background-color: #408fec;
color: #ffffff;
font-size: 15px;
text-decoration: none;
}

.login-button:hover {
background-color: #2f7fdc;
}
</style>
</head>
<body>
Expand All @@ -92,9 +107,10 @@
<div class="message__title">We can't find that Community.</div>
<p>
Check to make sure you've typed the URL correctly. If you're certain there should be
a Community here, please make sure you're <a href="https://www.pubpub.org/login" target="_blank" rel="noopener noreferrer">logged in</a> first and then reload this page, or
a Community here, it may only be visible to its members. If the problem persists,
<a href="mailto:help@pubpub.org">contact us</a>.
</p>
<!--LOGIN_SECTION-->
</div>
</body>
</html>
4 changes: 2 additions & 2 deletions server/routes/pubDocument.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ import { createLogger } from 'server/utils/queryHelpers/communityGet';
import { hostIsValid } from 'server/utils/routes';
import { generateMetaComponents, renderToNodeStream } from 'server/utils/ssr';
import { getCorrectHostname } from 'utils/caching/getCorrectHostname';
import { pubUrl } from 'utils/canonicalUrls';
import { canonicalPubUrl } from 'utils/canonicalUrls';
import { getNextCollectionPub } from 'utils/collections/getNextCollectionPub';
import { getPrimaryCollection } from 'utils/collections/primary';
import { getGoogleScholarNotes, getPdfDownloadUrl, getTextAbstract } from 'utils/pub/metadata';
Expand Down Expand Up @@ -75,7 +75,7 @@ const renderPubDocument = (
initialData,
notes: getGoogleScholarNotes(Object.values(pubData.initialStructuredCitations)),
publishedAt: getPubPublishedDate(pubData),
canonicalUrl: pubUrl(initialData.communityData, pubData),
canonicalUrl: canonicalPubUrl(initialData.communityData, pubData),
textAbstract: pubData.initialDoc ? getTextAbstract(pubData.initialDoc) : '',
title: pubData.title,
unlisted: !pubData.isRelease,
Expand Down
7 changes: 7 additions & 0 deletions server/routes/robots.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,13 @@ const buildRobotsFile = (community) => {
Disallow: /
`).trim();
}
if (community?.cmsMode) {
// CMS-mode communities are members-only; their canonical home is elsewhere
return stripIndent(`
User-agent: *
Disallow: /
`).trim();
}
if (community) {
return stripIndent(`
User-agent: *
Expand Down
6 changes: 6 additions & 0 deletions server/routes/sitemap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,9 @@ router.get(
}

const { communityData } = await getInitialData(req, { isDashboard: true });
if (communityData.cmsMode) {
return res.sendStatus(404);
}
const sitemapFileStream = await getSitemapIndex(communityData, 'sitemap-index.xml');

res.header('Content-Encoding', 'gzip');
Expand All @@ -158,6 +161,9 @@ router.get(
}

const { communityData } = await getInitialData(req, { isDashboard: true });
if (communityData.cmsMode) {
return res.sendStatus(404);
}
const sitemapFileStream = await getSitemapIndex(
communityData,
sitemapIndexOrSitemapFilename,
Expand Down
5 changes: 4 additions & 1 deletion server/rss/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,10 @@ import {
Release,
} from 'server/models';
import { sequelize } from 'server/sequelize';
import { communityUrl as getCommunityUrl, pubUrl } from 'utils/canonicalUrls';
import {
canonicalCommunityUrl as getCommunityUrl,
canonicalPubUrl as pubUrl,
} from 'utils/canonicalUrls';
import { sortByPrimaryStatus } from 'utils/collections/primary';
import { getAllPubContributors, getContributorName } from 'utils/contributors';
import { getFormattedDownloadUrl, getPublicExportUrl } from 'utils/pub/downloads';
Expand Down
2 changes: 1 addition & 1 deletion server/utils/citations/generateCitationHtml.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import Cite from 'citation-js';

import * as types from 'types';
import { pubUrl } from 'utils/canonicalUrls';
import { canonicalPubUrl as pubUrl } from 'utils/canonicalUrls';
import { type CitationStyleKind, renderJournalCitationForCitations } from 'utils/citations';
import getCollectionDoi from 'utils/collections/getCollectionDoi';
import { getPrimaryCollection } from 'utils/collections/primary';
Expand Down
Loading
Loading