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..2129db4488 100644 --- a/client/containers/DashboardOverview/overviewRows/labels.tsx +++ b/client/containers/DashboardOverview/overviewRows/labels.tsx @@ -132,5 +132,8 @@ export const renderLabelPairs = (iconLabelPairs: IconLabelPair[]) => { }; export const getTypicalPubLabels = (pub: Pub) => { + if (!pub.scopeSummary) { + return [getPubReleasedStateLabel(pub)]; + } return [...getScopeSummaryLabels(expect(pub.scopeSummary)), getPubReleasedStateLabel(pub)]; }; diff --git a/client/containers/SuperAdminDashboard/DepositTargets/DepositTargets.tsx b/client/containers/SuperAdminDashboard/DepositTargets/DepositTargets.tsx new file mode 100644 index 0000000000..092086b57e --- /dev/null +++ b/client/containers/SuperAdminDashboard/DepositTargets/DepositTargets.tsx @@ -0,0 +1,625 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; + +import { + Button, + Callout, + Checkbox, + Classes, + Dialog, + FormGroup, + HTMLSelect, + InputGroup, + Intent, + MenuItem, + NonIdealState, + Position, + Tag, +} from '@blueprintjs/core'; +import { Suggest } from '@blueprintjs/select'; + +import { apiFetch } from 'client/utils/apiFetch'; + +import './depositTargets.scss'; + +type DepositTargetRow = { + id: string; + communityId: string | null; + doiPrefix: string | null; + service: 'crossref' | 'datacite' | null; + hasCredentials: boolean; + communityTitle: string; + communitySubdomain: string; +}; + +type CommunityOption = { + id: string; + title: string; + subdomain: string; +}; + +type Props = { + depositTargets: DepositTargetRow[]; +}; + +const SERVICE_OPTIONS = [ + { label: 'Crossref', value: 'crossref' }, + { label: 'DataCite', value: 'datacite' }, +]; + +const useCommunitySearch = (excludeWithDepositTarget: boolean) => { + 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 β€” undefined means "untouched", '' means "user cleared the field" + const [editTarget, setEditTarget] = useState(null); + const [editDoiPrefix, setEditDoiPrefix] = useState(''); + const [editService, setEditService] = useState<'crossref' | 'datacite'>('crossref'); + const [editUsername, setEditUsername] = useState(undefined); + const [editPassword, setEditPassword] = useState(undefined); + + // 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(undefined); + setEditPassword(undefined); + }, []); + + const handleEdit = useCallback(async () => { + if (!editTarget) return; + setIsLoading(true); + setError(null); + setSuccess(null); + try { + const body: Record = { + doiPrefix: editDoiPrefix.trim(), + service: editService, + }; + if (editUsername !== undefined) { + body.username = editUsername.trim(); + } + if (editPassword !== undefined) { + 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 handleClearCredentials = useCallback(async (target: DepositTargetRow) => { + setPendingDelete(null); + setIsLoading(true); + setError(null); + setSuccess(null); + try { + const result = await apiFetch.delete( + `/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}).`); + } catch (err: any) { + setError(err?.message || 'Failed to clear credentials.'); + } 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)" + /> + )} +
+
+
+ + +
+
+
+ + {/* Clear Credentials Confirmation Dialog */} + setPendingDelete(null)} + title="Clear Credentials" + icon="warning-sign" + > +
+

+ Clear credentials for the deposit target on{' '} + {pendingDelete?.communityTitle} ( + {pendingDelete?.doiPrefix})? +

+

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

+
+
+
+ + +
+
+
+
+ ); +}; + +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..354bd6eca6 100644 --- a/server/depositTarget/model.ts +++ b/server/depositTarget/model.ts @@ -2,7 +2,17 @@ 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 +29,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/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..bb748ae304 100644 --- a/server/doi/submit.ts +++ b/server/doi/submit.ts @@ -68,7 +68,14 @@ 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 891037b9af..c52a15ee57 100644 --- a/server/routes/superAdminDashboard.tsx +++ b/server/routes/superAdminDashboard.tsx @@ -20,12 +20,13 @@ import { getEduDomainSummaries, } from 'server/community/eduQueries'; import { getAllTemplates } from 'server/communityTemplate/queries'; +import { env } from 'server/env'; import { getExploreCommunities } from 'server/exploreFeatured/queries'; 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 +37,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 +76,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 +274,268 @@ router.delete('/api/superadmin/custom-domains', async (req, res, next) => { } }); +// ── Deposit Targets API ──────────────────────────────────────────────────── + +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 + ? 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 resolveCommunity(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, + doiPrefix: String(doiPrefix).trim(), + service: service || 'crossref', + }; + + if (username && password) { + const { encryptedText, initVec } = aes256Encrypt(password, 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, + env.AES_ENCRYPTION_KEY!, + ); + updates.password = encryptedText; + updates.passwordInitVec = initVec; + } + } + } else if (password) { + const { encryptedText, initVec } = aes256Encrypt(password, 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.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); + } +}); + +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 resolveCommunity(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, + env.AES_ENCRYPTION_KEY!, + source.passwordInitVec, + ); + const { encryptedText, initVec } = aes256Encrypt(plaintext, 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',