From c31741756d551a686945ef1c508a57a023d1689a Mon Sep 17 00:00:00 2001 From: "Thomas F. K. Jorna" Date: Thu, 28 May 2026 17:15:48 +0200 Subject: [PATCH 1/7] feat: add superadmin tab for doi management --- .../DevCommunitySwitcherMenu/communities.ts | 1 + .../DashboardOverview/overviewRows/labels.tsx | 4 + .../PubSettings/PubSettings.tsx | 1 + .../DepositTargets/DepositTargets.tsx | 623 ++++++++++++++++++ .../DepositTargets/depositTargets.scss | 109 +++ .../DepositTargets/index.ts | 1 + .../containers/SuperAdminDashboard/tabs.tsx | 5 + server/depositTarget/model.ts | 7 +- server/routes/superAdminDashboard.tsx | 284 +++++++- utils/superAdmin.ts | 1 + 10 files changed, 1034 insertions(+), 2 deletions(-) create mode 100644 client/containers/SuperAdminDashboard/DepositTargets/DepositTargets.tsx create mode 100644 client/containers/SuperAdminDashboard/DepositTargets/depositTargets.scss create mode 100644 client/containers/SuperAdminDashboard/DepositTargets/index.ts diff --git a/client/components/DevCommunitySwitcherMenu/communities.ts b/client/components/DevCommunitySwitcherMenu/communities.ts index fb2c1989ee..0ee8971b84 100644 --- a/client/components/DevCommunitySwitcherMenu/communities.ts +++ b/client/components/DevCommunitySwitcherMenu/communities.ts @@ -17,6 +17,7 @@ if (process.env.NODE_ENV !== 'production') { { title: 'HDSR', subdomain: 'hdsr', icon: 'regression-chart' }, { title: 'BAAS', subdomain: 'baas', icon: 'moon' }, { title: 'Cursor', subdomain: 'cursor', icon: 'book' }, + { title: 'JOTE', subdomain: 'jtrialerror', icon: 'lab-test' }, ]; } diff --git a/client/containers/DashboardOverview/overviewRows/labels.tsx b/client/containers/DashboardOverview/overviewRows/labels.tsx index 61a2ceb2c7..5072439a94 100644 --- a/client/containers/DashboardOverview/overviewRows/labels.tsx +++ b/client/containers/DashboardOverview/overviewRows/labels.tsx @@ -132,5 +132,9 @@ export const renderLabelPairs = (iconLabelPairs: IconLabelPair[]) => { }; export const getTypicalPubLabels = (pub: Pub) => { + if (!pub.scopeSummary) { + console.error(`No scope summary for pub "${pub.title}" (${pub.slug})`); + return [getPubReleasedStateLabel(pub)]; + } return [...getScopeSummaryLabels(expect(pub.scopeSummary)), getPubReleasedStateLabel(pub)]; }; diff --git a/client/containers/DashboardSettings/PubSettings/PubSettings.tsx b/client/containers/DashboardSettings/PubSettings/PubSettings.tsx index 30e7790763..44127b7f87 100644 --- a/client/containers/DashboardSettings/PubSettings/PubSettings.tsx +++ b/client/containers/DashboardSettings/PubSettings/PubSettings.tsx @@ -184,6 +184,7 @@ const PubSettings = (props: Props) => { }; const renderDoi = () => { + console.log('BBBBBBBB'); return ( { + const [query, setQuery] = useState(''); + const [results, setResults] = useState([]); + const [selected, setSelected] = useState(null); + const debounceRef = useRef>(); + + useEffect(() => { + if (debounceRef.current) clearTimeout(debounceRef.current); + if (!query.trim()) { + setResults([]); + return; + } + debounceRef.current = setTimeout(async () => { + try { + const params = new URLSearchParams({ q: query }); + if (excludeWithDepositTarget) { + params.set('excludeWithDepositTarget', 'true'); + } + const data = await apiFetch.get( + `/api/superadmin/communities/search?${params}`, + ); + setResults(data); + } catch { + setResults([]); + } + }, 250); + return () => { + if (debounceRef.current) clearTimeout(debounceRef.current); + }; + }, [query, excludeWithDepositTarget]); + + const reset = useCallback(() => { + setQuery(''); + setResults([]); + setSelected(null); + }, []); + + return { query, setQuery, results, selected, setSelected, reset }; +}; + +const DepositTargets = (props: Props) => { + const [targets, setTargets] = useState(props.depositTargets); + const [filterText, setFilterText] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(null); + + // Create form + const createSearch = useCommunitySearch(true); + const [createDoiPrefix, setCreateDoiPrefix] = useState(''); + const [createService, setCreateService] = useState<'crossref' | 'datacite'>('crossref'); + const [createUsername, setCreateUsername] = useState(''); + const [createPassword, setCreatePassword] = useState(''); + + // Edit dialog + const [editTarget, setEditTarget] = useState(null); + const [editDoiPrefix, setEditDoiPrefix] = useState(''); + const [editService, setEditService] = useState<'crossref' | 'datacite'>('crossref'); + const [editUsername, setEditUsername] = useState(''); + const [editPassword, setEditPassword] = useState(''); + + // Copy dialog + const [copySource, setCopySource] = useState(null); + const copySearch = useCommunitySearch(true); + const [copyCredentials, setCopyCredentials] = useState(true); + + // Delete dialog + const [pendingDelete, setPendingDelete] = useState(null); + + const filteredTargets = useMemo(() => { + const q = filterText.toLowerCase().trim(); + if (!q) return targets; + return targets.filter( + (t) => + (t.doiPrefix ?? '').toLowerCase().includes(q) || + (t.service ?? '').toLowerCase().includes(q) || + t.communityTitle.toLowerCase().includes(q) || + t.communitySubdomain.toLowerCase().includes(q), + ); + }, [targets, filterText]); + + const handleCreate = useCallback(async () => { + if (!createSearch.selected || !createDoiPrefix.trim()) { + setError('Community and DOI prefix are required.'); + return; + } + setIsLoading(true); + setError(null); + setSuccess(null); + try { + const result = await apiFetch.post( + '/api/superadmin/deposit-targets', + { + communityId: createSearch.selected.id, + doiPrefix: createDoiPrefix.trim(), + service: createService, + username: createUsername.trim() || undefined, + password: createPassword || undefined, + }, + ); + setTargets((prev) => [result, ...prev]); + createSearch.reset(); + setCreateDoiPrefix(''); + setCreateService('crossref'); + setCreateUsername(''); + setCreatePassword(''); + setSuccess( + `Deposit target created for "${result.communityTitle}" (${result.doiPrefix}).`, + ); + } catch (err: any) { + setError(err?.message || 'Failed to create deposit target.'); + } finally { + setIsLoading(false); + } + }, [createSearch, createDoiPrefix, createService, createUsername, createPassword]); + + const openEdit = useCallback((target: DepositTargetRow) => { + setEditTarget(target); + setEditDoiPrefix(target.doiPrefix ?? ''); + setEditService((target.service as 'crossref' | 'datacite') ?? 'crossref'); + setEditUsername(''); + setEditPassword(''); + }, []); + + const handleEdit = useCallback(async () => { + if (!editTarget) return; + setIsLoading(true); + setError(null); + setSuccess(null); + try { + const body: Record = { + doiPrefix: editDoiPrefix.trim(), + service: editService, + }; + if (editUsername !== '') { + body.username = editUsername.trim(); + } + if (editPassword !== '') { + body.password = editPassword; + } + const result = await apiFetch.put( + `/api/superadmin/deposit-targets/${editTarget.id}`, + body, + ); + setTargets((prev) => prev.map((t) => (t.id === editTarget.id ? result : t))); + setEditTarget(null); + setSuccess(`Deposit target for "${result.communityTitle}" updated.`); + } catch (err: any) { + setError(err?.message || 'Failed to update deposit target.'); + } finally { + setIsLoading(false); + } + }, [editTarget, editDoiPrefix, editService, editUsername, editPassword]); + + const handleDelete = useCallback( + async (target: DepositTargetRow) => { + setPendingDelete(null); + setIsLoading(true); + setError(null); + setSuccess(null); + try { + await apiFetch.delete(`/api/superadmin/deposit-targets/${target.id}`); + setTargets((prev) => prev.filter((t) => t.id !== target.id)); + setSuccess( + `Deposit target for "${target.communityTitle}" (${target.doiPrefix}) deleted.`, + ); + } catch (err: any) { + setError(err?.message || 'Failed to delete deposit target.'); + } finally { + setIsLoading(false); + } + }, + [], + ); + + const handleCopy = useCallback(async () => { + if (!copySource || !copySearch.selected) { + setError('Destination community is required.'); + return; + } + setIsLoading(true); + setError(null); + setSuccess(null); + try { + const result = await apiFetch.post( + `/api/superadmin/deposit-targets/${copySource.id}/copy`, + { + communityId: copySearch.selected.id, + copyCredentials, + }, + ); + setTargets((prev) => [result, ...prev]); + setCopySource(null); + copySearch.reset(); + setCopyCredentials(true); + setSuccess( + `Deposit target copied to "${result.communityTitle}" (${result.doiPrefix}).`, + ); + } catch (err: any) { + setError(err?.message || 'Failed to copy deposit target.'); + } finally { + setIsLoading(false); + } + }, [copySource, copySearch, copyCredentials]); + + return ( +
+

Deposit Targets

+ + {error && ( + + {error} + + )} + {success && ( + + {success} + + )} + +

Create Deposit Target

+
+ + + items={createSearch.results} + query={createSearch.query} + onQueryChange={createSearch.setQuery} + selectedItem={createSearch.selected} + onItemSelect={(item) => { + createSearch.setSelected(item); + createSearch.setQuery(item.title); + }} + inputValueRenderer={(item) => item.title} + itemRenderer={(item, { handleClick, modifiers }) => ( + + )} + noResults={ + createSearch.query.trim() ? ( + + ) : undefined + } + resetOnSelect={false} + inputProps={{ + placeholder: 'Search communities…', + disabled: isLoading, + }} + popoverProps={{ + minimal: true, + position: Position.BOTTOM_LEFT, + }} + /> + + + setCreateDoiPrefix(e.target.value)} + disabled={isLoading} + /> + + + + setCreateService(e.target.value as 'crossref' | 'datacite') + } + options={SERVICE_OPTIONS} + disabled={isLoading} + /> + + + setCreateUsername(e.target.value)} + disabled={isLoading} + /> + + + setCreatePassword(e.target.value)} + disabled={isLoading} + /> + +
+ +

Manage Deposit Targets

+ {targets.length === 0 ? ( + + ) : ( + <> +
+ setFilterText(e.target.value)} + /> + + {filterText && + filteredTargets.length !== targets.length + ? `Showing ${filteredTargets.length} of ${targets.length}` + : `Total: ${targets.length}`}{' '} + target{targets.length !== 1 ? 's' : ''} + +
+ + + + + + + + + + + + {filteredTargets.map((t) => ( + + + + + + + + + ))} + +
DOI PrefixServiceCommunitySubdomainCredentials +
+ {t.doiPrefix} + + + {t.service ?? 'crossref'} + + {t.communityTitle} + {t.communitySubdomain} + + + {t.hasCredentials ? 'πŸ”’ Set' : 'None'} + + +
+ + )} + + {/* Edit Dialog */} + setEditTarget(null)} + title="Edit Deposit Target" + > +
+

+ Community: {editTarget?.communityTitle} ( + {editTarget?.communitySubdomain}) +

+ + setEditDoiPrefix(e.target.value)} + /> + + + + setEditService(e.target.value as 'crossref' | 'datacite') + } + options={SERVICE_OPTIONS} + /> + + + setEditUsername(e.target.value)} + /> + + + setEditPassword(e.target.value)} + /> + +
+
+
+ + +
+
+
+ + {/* Copy Dialog */} + { + setCopySource(null); + copySearch.reset(); + }} + title="Copy Deposit Target" + > +
+
+

+ Source: {copySource?.communityTitle} ( + {copySource?.communitySubdomain}) +

+

+ Prefix: {copySource?.doiPrefix} +

+

+ Service: {copySource?.service ?? 'crossref'} +

+
+ + + items={copySearch.results} + query={copySearch.query} + onQueryChange={copySearch.setQuery} + selectedItem={copySearch.selected} + onItemSelect={(item) => { + copySearch.setSelected(item); + copySearch.setQuery(item.title); + }} + inputValueRenderer={(item) => item.title} + itemRenderer={(item, { handleClick, modifiers }) => ( + + )} + noResults={ + copySearch.query.trim() ? ( + + ) : undefined + } + resetOnSelect={false} + inputProps={{ + placeholder: 'Search communities…', + }} + popoverProps={{ + minimal: true, + position: Position.BOTTOM_LEFT, + }} + /> + + {copySource?.hasCredentials && ( + + setCopyCredentials((e.target as HTMLInputElement).checked) + } + label="Copy credentials (username & password)" + /> + )} +
+
+
+ + +
+
+
+ + {/* Delete Confirmation Dialog */} + setPendingDelete(null)} + title="Delete Deposit Target" + icon="warning-sign" + > +
+

+ Delete the deposit target for{' '} + {pendingDelete?.communityTitle} ( + {pendingDelete?.doiPrefix})? +

+

This will remove DOI minting configuration for this community.

+
+
+
+ + +
+
+
+
+ ); +}; + +export default DepositTargets; diff --git a/client/containers/SuperAdminDashboard/DepositTargets/depositTargets.scss b/client/containers/SuperAdminDashboard/DepositTargets/depositTargets.scss new file mode 100644 index 0000000000..37fa160805 --- /dev/null +++ b/client/containers/SuperAdminDashboard/DepositTargets/depositTargets.scss @@ -0,0 +1,109 @@ +.deposit-targets-component { + .add-target-form { + display: flex; + gap: 10px; + margin-bottom: 20px; + align-items: flex-end; + flex-wrap: wrap; + + .bp3-form-group { + margin-bottom: 0; + } + } + + .filter-bar { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 10px; + + .bp3-input-group { + flex: 0 0 400px; + } + } + + .target-count { + font-size: 13px; + color: #888; + white-space: nowrap; + } + + .targets-table { + width: 100%; + border-collapse: collapse; + table-layout: fixed; + + th, + td { + padding: 8px 12px; + text-align: left; + border-bottom: 1px solid #e1e1e1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + th:nth-child(1), + td:nth-child(1) { width: 12%; } + + th:nth-child(2), + td:nth-child(2) { width: 10%; } + + th:nth-child(3), + td:nth-child(3) { width: 28%; } + + th:nth-child(4), + td:nth-child(4) { width: 16%; } + + th:nth-child(5), + td:nth-child(5) { width: 10%; } + + th:nth-child(6), + td:nth-child(6) { width: 100px; text-align: right; } + + th { + font-weight: 600; + background: #f5f5f5; + } + + .community-link { + color: #137cbd; + text-decoration: none; + + &:hover { + text-decoration: underline; + } + } + } + + .credential-badge { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 12px; + + &.has-credentials { + color: #0d8050; + } + + &.no-credentials { + color: #888; + } + } + + .dialog-field { + margin-bottom: 12px; + } + + .copy-source-info { + margin-bottom: 16px; + padding: 10px 14px; + background: #f5f5f5; + border-radius: 4px; + + p { + margin: 4px 0; + font-size: 13px; + } + } +} diff --git a/client/containers/SuperAdminDashboard/DepositTargets/index.ts b/client/containers/SuperAdminDashboard/DepositTargets/index.ts new file mode 100644 index 0000000000..043fa4cee8 --- /dev/null +++ b/client/containers/SuperAdminDashboard/DepositTargets/index.ts @@ -0,0 +1 @@ +export { default } from './DepositTargets'; diff --git a/client/containers/SuperAdminDashboard/tabs.tsx b/client/containers/SuperAdminDashboard/tabs.tsx index 53b48b61d1..cf77a25da3 100644 --- a/client/containers/SuperAdminDashboard/tabs.tsx +++ b/client/containers/SuperAdminDashboard/tabs.tsx @@ -5,6 +5,7 @@ import React from 'react'; import CommunitySpam from './CommunitySpam'; import CommunityTemplates from './CommunityTemplates'; import CustomDomains from './CustomDomains'; +import DepositTargets from './DepositTargets'; import ExploreCommunities from './ExploreCommunities'; import Hubs from './Hubs'; import LandingPageFeatures from './LandingPageFeatures'; @@ -26,6 +27,10 @@ export const superAdminTabs: Record = { title: 'Custom Domains', component: CustomDomains, }, + depositTargets: { + title: 'Deposit Targets', + component: DepositTargets, + }, exploreCommunities: { title: 'Explore Page', component: ExploreCommunities, diff --git a/server/depositTarget/model.ts b/server/depositTarget/model.ts index 0fd7e3baf1..85abb58608 100644 --- a/server/depositTarget/model.ts +++ b/server/depositTarget/model.ts @@ -2,7 +2,9 @@ import type { CreationOptional, InferAttributes, InferCreationAttributes } from import type { SerializedModel } from 'types'; -import { Column, DataType, Default, Model, PrimaryKey, Table } from 'sequelize-typescript'; +import { BelongsTo, Column, DataType, Default, Model, PrimaryKey, Table } from 'sequelize-typescript'; + +import { Community } from '../community/model'; @Table export class DepositTarget extends Model< @@ -19,6 +21,9 @@ export class DepositTarget extends Model< @Column(DataType.UUID) declare communityId: string | null; + @BelongsTo(() => Community, { onDelete: 'CASCADE', as: 'community', foreignKey: 'communityId' }) + declare community?: Community; + @Column(DataType.STRING) declare doiPrefix: string | null; diff --git a/server/routes/superAdminDashboard.tsx b/server/routes/superAdminDashboard.tsx index 891037b9af..c9220d8fd6 100644 --- a/server/routes/superAdminDashboard.tsx +++ b/server/routes/superAdminDashboard.tsx @@ -25,7 +25,7 @@ import Html from 'server/Html'; // NOTE: Suggested Hubs SSR returns an empty shell; summaries are fetched client-side on mount. import { getAllHubsWithCommunityCounts } from 'server/hub/queries'; import { getLandingPageFeatures } from 'server/landingPageFeature/queries'; -import { Community } from 'server/models'; +import { Community, DepositTarget } from 'server/models'; import { queryCommunitiesForSpamManagement } from 'server/spamTag/communityDashboard'; import { queryUsersForSpamManagement } from 'server/spamTag/userDashboard'; import { @@ -36,6 +36,7 @@ import { import { BadRequestError, ForbiddenError, handleErrors, NotFoundError } from 'server/utils/errors'; import { getInitialData } from 'server/utils/initData'; import { generateMetaComponents, renderToNodeStream } from 'server/utils/ssr'; +import { aes256Decrypt, aes256Encrypt } from 'utils/crypto'; import { getSuperAdminTabUrl, isSuperAdminTabKind, @@ -74,6 +75,29 @@ const getTabProps = async (tabKind: SuperAdminTabKind, locationData: types.Locat }); return { communities, cloudflareConfigured: isCloudflareConfigured() }; } + if (tabKind === 'depositTargets') { + const targets = await DepositTarget.findAll({ + include: [ + { + model: Community, + as: 'community', + attributes: ['id', 'title', 'subdomain'], + }, + ], + order: [['createdAt', 'DESC']], + }); + return { + depositTargets: targets.map((t) => ({ + id: t.id, + communityId: t.communityId, + doiPrefix: t.doiPrefix, + service: t.service, + hasCredentials: Boolean(t.username), + communityTitle: t.community?.title ?? '(unknown)', + communitySubdomain: t.community?.subdomain ?? '(unknown)', + })), + }; + } if (tabKind === 'exploreCommunities') { return { communities: await getExploreCommunities() }; } @@ -249,6 +273,264 @@ router.delete('/api/superadmin/custom-domains', async (req, res, next) => { } }); +// ── Deposit Targets API ──────────────────────────────────────────────────── + +const resolveCommmunity = async (identifier: string) => { + const trimmed = String(identifier).trim(); + const isUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(trimmed); + const community = isUuid + ? await Community.findByPk(trimmed) + : await Community.findOne({ where: { subdomain: trimmed } }); + if (!community) { + throw new NotFoundError(new Error('Community not found')); + } + return community; +}; + +const sanitizeDepositTarget = (t: DepositTarget) => ({ + id: t.id, + communityId: t.communityId, + doiPrefix: t.doiPrefix, + service: t.service, + hasCredentials: Boolean(t.username), + communityTitle: t.community?.title ?? '(unknown)', + communitySubdomain: t.community?.subdomain ?? '(unknown)', +}); + +router.get('/api/superadmin/communities/search', async (req, res, next) => { + try { + const initialData = await getInitialData(req); + if (!initialData.loginData.isSuperAdmin) { + throw new ForbiddenError(); + } + + const q = String(req.query.q ?? '') + .trim() + .toLowerCase(); + if (!q) { + return res.json([]); + } + + const excludeWithDepositTarget = req.query.excludeWithDepositTarget === 'true'; + + const communities = await Community.findAll({ + where: { + [Op.or]: [ + { title: { [Op.iLike]: `%${q}%` } }, + { subdomain: { [Op.iLike]: `%${q}%` } }, + ], + }, + attributes: ['id', 'title', 'subdomain'], + limit: 20, + order: [['title', 'ASC']], + }); + + if (!excludeWithDepositTarget) { + return res.json(communities); + } + + const communityIds = communities.map((c) => c.id); + const existingTargets = await DepositTarget.findAll({ + where: { communityId: communityIds }, + attributes: ['communityId'], + }); + const idsWithTarget = new Set(existingTargets.map((t) => t.communityId)); + return res.json(communities.filter((c) => !idsWithTarget.has(c.id))); + } catch (err) { + return handleErrors(req, res, next)(err); + } +}); + +router.post('/api/superadmin/deposit-targets', async (req, res, next) => { + try { + const initialData = await getInitialData(req); + if (!initialData.loginData.isSuperAdmin) { + throw new ForbiddenError(); + } + + const { communityId, doiPrefix, service, username, password } = req.body; + if (!communityId || !doiPrefix) { + throw new BadRequestError(new Error('communityId and doiPrefix are required')); + } + if (service && !['crossref', 'datacite'].includes(service)) { + throw new BadRequestError(new Error('service must be "crossref" or "datacite"')); + } + + const community = await resolveCommmunity(communityId); + + const createData: Record = { + communityId: community.id, + doiPrefix: String(doiPrefix).trim(), + service: service || 'crossref', + }; + + if (username && password) { + const { encryptedText, initVec } = aes256Encrypt( + password, + process.env.AES_ENCRYPTION_KEY!, + ); + createData.username = username; + createData.password = encryptedText; + createData.passwordInitVec = initVec; + } + + const target = await DepositTarget.create(createData as any); + const reloaded = await DepositTarget.findByPk(target.id, { + include: [ + { model: Community, as: 'community', attributes: ['id', 'title', 'subdomain'] }, + ], + }); + + return res.json(sanitizeDepositTarget(reloaded!)); + } catch (err) { + return handleErrors(req, res, next)(err); + } +}); + +router.put('/api/superadmin/deposit-targets/:id', async (req, res, next) => { + try { + const initialData = await getInitialData(req); + if (!initialData.loginData.isSuperAdmin) { + throw new ForbiddenError(); + } + + const target = await DepositTarget.findByPk(req.params.id); + if (!target) { + throw new NotFoundError(new Error('Deposit target not found')); + } + + const { doiPrefix, service, username, password } = req.body; + const updates: Record = {}; + + if (doiPrefix !== undefined) { + updates.doiPrefix = String(doiPrefix).trim(); + } + if (service !== undefined) { + if (!['crossref', 'datacite'].includes(service)) { + throw new BadRequestError(new Error('service must be "crossref" or "datacite"')); + } + updates.service = service; + } + + if (username !== undefined) { + if (username === '') { + updates.username = null; + updates.password = null; + updates.passwordInitVec = null; + } else { + updates.username = username; + if (password) { + const { encryptedText, initVec } = aes256Encrypt( + password, + process.env.AES_ENCRYPTION_KEY!, + ); + updates.password = encryptedText; + updates.passwordInitVec = initVec; + } + } + } else if (password) { + const { encryptedText, initVec } = aes256Encrypt( + password, + process.env.AES_ENCRYPTION_KEY!, + ); + updates.password = encryptedText; + updates.passwordInitVec = initVec; + } + + await target.update(updates); + const reloaded = await DepositTarget.findByPk(target.id, { + include: [ + { model: Community, as: 'community', attributes: ['id', 'title', 'subdomain'] }, + ], + }); + + return res.json(sanitizeDepositTarget(reloaded!)); + } catch (err) { + return handleErrors(req, res, next)(err); + } +}); + +router.delete('/api/superadmin/deposit-targets/:id', async (req, res, next) => { + try { + const initialData = await getInitialData(req); + if (!initialData.loginData.isSuperAdmin) { + throw new ForbiddenError(); + } + + const target = await DepositTarget.findByPk(req.params.id); + if (!target) { + throw new NotFoundError(new Error('Deposit target not found')); + } + + await target.destroy(); + return res.json({ success: true }); + } catch (err) { + return handleErrors(req, res, next)(err); + } +}); + +router.post('/api/superadmin/deposit-targets/:id/copy', async (req, res, next) => { + try { + const initialData = await getInitialData(req); + if (!initialData.loginData.isSuperAdmin) { + throw new ForbiddenError(); + } + + const source = await DepositTarget.findByPk(req.params.id); + if (!source) { + throw new NotFoundError(new Error('Source deposit target not found')); + } + + const { communityId, copyCredentials } = req.body; + if (!communityId) { + throw new BadRequestError(new Error('communityId is required')); + } + + const destCommunity = await resolveCommmunity(communityId); + + const existing = await DepositTarget.findOne({ + where: { communityId: destCommunity.id }, + }); + if (existing) { + throw new BadRequestError( + new Error('Destination community already has a deposit target'), + ); + } + + const createData: Record = { + communityId: destCommunity.id, + doiPrefix: source.doiPrefix, + service: source.service, + }; + + if (copyCredentials && source.username && source.password && source.passwordInitVec) { + const plaintext = aes256Decrypt( + source.password, + process.env.AES_ENCRYPTION_KEY!, + source.passwordInitVec, + ); + const { encryptedText, initVec } = aes256Encrypt( + plaintext, + process.env.AES_ENCRYPTION_KEY!, + ); + createData.username = source.username; + createData.password = encryptedText; + createData.passwordInitVec = initVec; + } + + const newTarget = await DepositTarget.create(createData as any); + const reloaded = await DepositTarget.findByPk(newTarget.id, { + include: [ + { model: Community, as: 'community', attributes: ['id', 'title', 'subdomain'] }, + ], + }); + + return res.json(sanitizeDepositTarget(reloaded!)); + } catch (err) { + return handleErrors(req, res, next)(err); + } +}); + // JSON API for lazy-loading suggested-hubs sidebar summaries router.get('/api/superadmin/suggested-hubs-summaries', async (req, res, next) => { try { diff --git a/utils/superAdmin.ts b/utils/superAdmin.ts index 57b698e4c1..a571bfc107 100644 --- a/utils/superAdmin.ts +++ b/utils/superAdmin.ts @@ -1,6 +1,7 @@ export const superAdminTabKinds = [ 'analytics', 'customDomains', + 'depositTargets', 'exploreCommunities', 'landingPageFeatures', 'hubs', From 9f3d4743720393bdb70e313f33d6cca9afe638b8 Mon Sep 17 00:00:00 2001 From: "Thomas F. K. Jorna" Date: Thu, 28 May 2026 17:16:34 +0200 Subject: [PATCH 2/7] chore: format --- .../DepositTargets/DepositTargets.tsx | 40 +++++++++---------- server/depositTarget/model.ts | 10 ++++- 2 files changed, 27 insertions(+), 23 deletions(-) diff --git a/client/containers/SuperAdminDashboard/DepositTargets/DepositTargets.tsx b/client/containers/SuperAdminDashboard/DepositTargets/DepositTargets.tsx index e8cd2c7311..861c6bb54e 100644 --- a/client/containers/SuperAdminDashboard/DepositTargets/DepositTargets.tsx +++ b/client/containers/SuperAdminDashboard/DepositTargets/DepositTargets.tsx @@ -200,26 +200,23 @@ const DepositTargets = (props: Props) => { } }, [editTarget, editDoiPrefix, editService, editUsername, editPassword]); - const handleDelete = useCallback( - async (target: DepositTargetRow) => { - setPendingDelete(null); - setIsLoading(true); - setError(null); - setSuccess(null); - try { - await apiFetch.delete(`/api/superadmin/deposit-targets/${target.id}`); - setTargets((prev) => prev.filter((t) => t.id !== target.id)); - setSuccess( - `Deposit target for "${target.communityTitle}" (${target.doiPrefix}) deleted.`, - ); - } catch (err: any) { - setError(err?.message || 'Failed to delete deposit target.'); - } finally { - setIsLoading(false); - } - }, - [], - ); + const handleDelete = useCallback(async (target: DepositTargetRow) => { + setPendingDelete(null); + setIsLoading(true); + setError(null); + setSuccess(null); + try { + await apiFetch.delete(`/api/superadmin/deposit-targets/${target.id}`); + setTargets((prev) => prev.filter((t) => t.id !== target.id)); + setSuccess( + `Deposit target for "${target.communityTitle}" (${target.doiPrefix}) deleted.`, + ); + } catch (err: any) { + setError(err?.message || 'Failed to delete deposit target.'); + } finally { + setIsLoading(false); + } + }, []); const handleCopy = useCallback(async () => { if (!copySource || !copySearch.selected) { @@ -365,8 +362,7 @@ const DepositTargets = (props: Props) => { onChange={(e) => setFilterText(e.target.value)} /> - {filterText && - filteredTargets.length !== targets.length + {filterText && filteredTargets.length !== targets.length ? `Showing ${filteredTargets.length} of ${targets.length}` : `Total: ${targets.length}`}{' '} target{targets.length !== 1 ? 's' : ''} diff --git a/server/depositTarget/model.ts b/server/depositTarget/model.ts index 85abb58608..354bd6eca6 100644 --- a/server/depositTarget/model.ts +++ b/server/depositTarget/model.ts @@ -2,7 +2,15 @@ import type { CreationOptional, InferAttributes, InferCreationAttributes } from import type { SerializedModel } from 'types'; -import { BelongsTo, Column, DataType, Default, Model, PrimaryKey, Table } from 'sequelize-typescript'; +import { + BelongsTo, + Column, + DataType, + Default, + Model, + PrimaryKey, + Table, +} from 'sequelize-typescript'; import { Community } from '../community/model'; From 131373e5cce16ce2303101705f89960f30bc01ff Mon Sep 17 00:00:00 2001 From: "Thomas F. K. Jorna" Date: Thu, 28 May 2026 17:47:57 +0200 Subject: [PATCH 3/7] fix: make delete remove creds instead --- .../DepositTargets/DepositTargets.tsx | 50 +++++++++++-------- server/doi/api.ts | 6 +++ server/doi/submit.ts | 6 ++- server/routes/superAdminDashboard.tsx | 7 ++- 4 files changed, 46 insertions(+), 23 deletions(-) diff --git a/client/containers/SuperAdminDashboard/DepositTargets/DepositTargets.tsx b/client/containers/SuperAdminDashboard/DepositTargets/DepositTargets.tsx index 861c6bb54e..1125816b25 100644 --- a/client/containers/SuperAdminDashboard/DepositTargets/DepositTargets.tsx +++ b/client/containers/SuperAdminDashboard/DepositTargets/DepositTargets.tsx @@ -200,19 +200,21 @@ const DepositTargets = (props: Props) => { } }, [editTarget, editDoiPrefix, editService, editUsername, editPassword]); - const handleDelete = useCallback(async (target: DepositTargetRow) => { + const handleClearCredentials = useCallback(async (target: DepositTargetRow) => { setPendingDelete(null); setIsLoading(true); setError(null); setSuccess(null); try { - await apiFetch.delete(`/api/superadmin/deposit-targets/${target.id}`); - setTargets((prev) => prev.filter((t) => t.id !== target.id)); + const result = await apiFetch.delete( + `/api/superadmin/deposit-targets/${target.id}`, + ); + setTargets((prev) => prev.map((t) => (t.id === target.id ? result : t))); setSuccess( - `Deposit target for "${target.communityTitle}" (${target.doiPrefix}) deleted.`, + `Credentials cleared for "${target.communityTitle}" (${target.doiPrefix}).`, ); } catch (err: any) { - setError(err?.message || 'Failed to delete deposit target.'); + setError(err?.message || 'Failed to clear credentials.'); } finally { setIsLoading(false); } @@ -423,14 +425,17 @@ const DepositTargets = (props: Props) => { onClick={() => setCopySource(t)} disabled={isLoading} /> - diff --git a/server/doi/api.ts b/server/doi/api.ts index 8afee8b0a6..068b47184f 100644 --- a/server/doi/api.ts +++ b/server/doi/api.ts @@ -101,6 +101,9 @@ router.post( error: parentToSupplementNeedsDoiError.message, }); } + if (err instanceof Error && err.message) { + return res.status(400).json({ error: err.message }); + } throw err; } }), @@ -123,6 +126,9 @@ router.get( if (err === parentToSupplementNeedsDoiError) { return res.status(400).json({ error: parentToSupplementNeedsDoiError.message }); } + if (err instanceof Error && err.message) { + return res.status(400).json({ error: err.message }); + } throw err; } }), diff --git a/server/doi/submit.ts b/server/doi/submit.ts index 7978d04edd..d67500a812 100644 --- a/server/doi/submit.ts +++ b/server/doi/submit.ts @@ -68,7 +68,11 @@ export const postToCrossref = async (opts: { const body = await response.text(); if (!response.ok) { - throw new Error(`Crossref submission failed (${response.status}): ${body}`); + const stripped = body.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim(); + const message = stripped.length > 300 ? `${stripped.slice(0, 300)}…` : stripped; + throw new Error( + `Crossref submission failed (${response.status}): ${message || 'Unknown error'}`, + ); } return body; }; diff --git a/server/routes/superAdminDashboard.tsx b/server/routes/superAdminDashboard.tsx index c9220d8fd6..83837281e2 100644 --- a/server/routes/superAdminDashboard.tsx +++ b/server/routes/superAdminDashboard.tsx @@ -462,8 +462,11 @@ router.delete('/api/superadmin/deposit-targets/:id', async (req, res, next) => { throw new NotFoundError(new Error('Deposit target not found')); } - await target.destroy(); - return res.json({ success: true }); + await target.update({ username: null, password: null, passwordInitVec: null }); + const reloaded = await DepositTarget.findByPk(target.id, { + include: [{ model: Community, as: 'community', attributes: ['id', 'title', 'subdomain'] }], + }); + return res.json(sanitizeDepositTarget(reloaded!)); } catch (err) { return handleErrors(req, res, next)(err); } From d331bd90b99d76590fb9306ec63d5dea9264d07e Mon Sep 17 00:00:00 2001 From: "Thomas F. K. Jorna" Date: Thu, 28 May 2026 17:50:31 +0200 Subject: [PATCH 4/7] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- server/routes/superAdminDashboard.tsx | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/server/routes/superAdminDashboard.tsx b/server/routes/superAdminDashboard.tsx index 83837281e2..51837c11f6 100644 --- a/server/routes/superAdminDashboard.tsx +++ b/server/routes/superAdminDashboard.tsx @@ -357,6 +357,14 @@ router.post('/api/superadmin/deposit-targets', async (req, res, next) => { } const community = await resolveCommmunity(communityId); + const existingTarget = await DepositTarget.findOne({ + where: { communityId: community.id }, + }); + if (existingTarget) { + throw new BadRequestError( + new Error('A deposit target already exists for this community'), + ); + } const createData: Record = { communityId: community.id, From 1e70a2031851ccdb6150a6a6a641f8312772472e Mon Sep 17 00:00:00 2001 From: "Thomas F. K. Jorna" Date: Thu, 28 May 2026 17:51:40 +0200 Subject: [PATCH 5/7] fix: fixes --- .../DashboardOverview/overviewRows/labels.tsx | 1 - .../PubSettings/PubSettings.tsx | 1 - server/routes/superAdminDashboard.tsx | 30 ++++++++----------- 3 files changed, 12 insertions(+), 20 deletions(-) diff --git a/client/containers/DashboardOverview/overviewRows/labels.tsx b/client/containers/DashboardOverview/overviewRows/labels.tsx index 5072439a94..2129db4488 100644 --- a/client/containers/DashboardOverview/overviewRows/labels.tsx +++ b/client/containers/DashboardOverview/overviewRows/labels.tsx @@ -133,7 +133,6 @@ export const renderLabelPairs = (iconLabelPairs: IconLabelPair[]) => { export const getTypicalPubLabels = (pub: Pub) => { if (!pub.scopeSummary) { - console.error(`No scope summary for pub "${pub.title}" (${pub.slug})`); return [getPubReleasedStateLabel(pub)]; } return [...getScopeSummaryLabels(expect(pub.scopeSummary)), getPubReleasedStateLabel(pub)]; diff --git a/client/containers/DashboardSettings/PubSettings/PubSettings.tsx b/client/containers/DashboardSettings/PubSettings/PubSettings.tsx index 44127b7f87..30e7790763 100644 --- a/client/containers/DashboardSettings/PubSettings/PubSettings.tsx +++ b/client/containers/DashboardSettings/PubSettings/PubSettings.tsx @@ -184,7 +184,6 @@ const PubSettings = (props: Props) => { }; const renderDoi = () => { - console.log('BBBBBBBB'); return ( { // ── Deposit Targets API ──────────────────────────────────────────────────── -const resolveCommmunity = async (identifier: string) => { +const resolveCommunity = async (identifier: string) => { const trimmed = String(identifier).trim(); const isUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(trimmed); const community = isUuid @@ -356,7 +357,7 @@ router.post('/api/superadmin/deposit-targets', async (req, res, next) => { throw new BadRequestError(new Error('service must be "crossref" or "datacite"')); } - const community = await resolveCommmunity(communityId); + const community = await resolveCommunity(communityId); const createData: Record = { communityId: community.id, @@ -365,10 +366,7 @@ router.post('/api/superadmin/deposit-targets', async (req, res, next) => { }; if (username && password) { - const { encryptedText, initVec } = aes256Encrypt( - password, - process.env.AES_ENCRYPTION_KEY!, - ); + const { encryptedText, initVec } = aes256Encrypt(password, env.AES_ENCRYPTION_KEY!); createData.username = username; createData.password = encryptedText; createData.passwordInitVec = initVec; @@ -422,17 +420,14 @@ router.put('/api/superadmin/deposit-targets/:id', async (req, res, next) => { if (password) { const { encryptedText, initVec } = aes256Encrypt( password, - process.env.AES_ENCRYPTION_KEY!, + env.AES_ENCRYPTION_KEY!, ); updates.password = encryptedText; updates.passwordInitVec = initVec; } } } else if (password) { - const { encryptedText, initVec } = aes256Encrypt( - password, - process.env.AES_ENCRYPTION_KEY!, - ); + const { encryptedText, initVec } = aes256Encrypt(password, env.AES_ENCRYPTION_KEY!); updates.password = encryptedText; updates.passwordInitVec = initVec; } @@ -464,7 +459,9 @@ router.delete('/api/superadmin/deposit-targets/:id', async (req, res, next) => { await target.update({ username: null, password: null, passwordInitVec: null }); const reloaded = await DepositTarget.findByPk(target.id, { - include: [{ model: Community, as: 'community', attributes: ['id', 'title', 'subdomain'] }], + include: [ + { model: Community, as: 'community', attributes: ['id', 'title', 'subdomain'] }, + ], }); return res.json(sanitizeDepositTarget(reloaded!)); } catch (err) { @@ -489,7 +486,7 @@ router.post('/api/superadmin/deposit-targets/:id/copy', async (req, res, next) = throw new BadRequestError(new Error('communityId is required')); } - const destCommunity = await resolveCommmunity(communityId); + const destCommunity = await resolveCommunity(communityId); const existing = await DepositTarget.findOne({ where: { communityId: destCommunity.id }, @@ -509,13 +506,10 @@ router.post('/api/superadmin/deposit-targets/:id/copy', async (req, res, next) = if (copyCredentials && source.username && source.password && source.passwordInitVec) { const plaintext = aes256Decrypt( source.password, - process.env.AES_ENCRYPTION_KEY!, + env.AES_ENCRYPTION_KEY!, source.passwordInitVec, ); - const { encryptedText, initVec } = aes256Encrypt( - plaintext, - process.env.AES_ENCRYPTION_KEY!, - ); + const { encryptedText, initVec } = aes256Encrypt(plaintext, env.AES_ENCRYPTION_KEY!); createData.username = source.username; createData.password = encryptedText; createData.passwordInitVec = initVec; From cbc1d7ea2ee79d3eaf3c11750fd95ef98dca0c4b Mon Sep 17 00:00:00 2001 From: "Thomas F. K. Jorna" Date: Thu, 28 May 2026 17:52:41 +0200 Subject: [PATCH 6/7] chore: lint --- .../DepositTargets/DepositTargets.tsx | 12 ++++-------- server/doi/submit.ts | 5 ++++- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/client/containers/SuperAdminDashboard/DepositTargets/DepositTargets.tsx b/client/containers/SuperAdminDashboard/DepositTargets/DepositTargets.tsx index 1125816b25..b373de6d5e 100644 --- a/client/containers/SuperAdminDashboard/DepositTargets/DepositTargets.tsx +++ b/client/containers/SuperAdminDashboard/DepositTargets/DepositTargets.tsx @@ -210,9 +210,7 @@ const DepositTargets = (props: Props) => { `/api/superadmin/deposit-targets/${target.id}`, ); setTargets((prev) => prev.map((t) => (t.id === target.id ? result : t))); - setSuccess( - `Credentials cleared for "${target.communityTitle}" (${target.doiPrefix}).`, - ); + setSuccess(`Credentials cleared for "${target.communityTitle}" (${target.doiPrefix}).`); } catch (err: any) { setError(err?.message || 'Failed to clear credentials.'); } finally { @@ -603,8 +601,8 @@ const DepositTargets = (props: Props) => { {pendingDelete?.doiPrefix})?

- The deposit target will remain but the community will no longer - be able to mint DOIs until new credentials are set. + The deposit target will remain but the community will no longer be able to + mint DOIs until new credentials are set.

@@ -612,9 +610,7 @@ const DepositTargets = (props: Props) => {