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' : ''}
+
+
+
+
+
+ DOI Prefix
+ Service
+ Community
+ Subdomain
+ Credentials
+
+
+
+
+ {filteredTargets.map((t) => (
+
+
+ {t.doiPrefix}
+
+
+
+ {t.service ?? 'crossref'}
+
+
+ {t.communityTitle}
+
+ {t.communitySubdomain}
+
+
+
+ {t.hasCredentials ? 'π Set' : 'None'}
+
+
+
+ openEdit(t)}
+ disabled={isLoading}
+ />
+ setCopySource(t)}
+ disabled={isLoading}
+ />
+ setPendingDelete(t)}
+ disabled={isLoading}
+ />
+
+
+ ))}
+
+
+ >
+ )}
+
+ {/* 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)}
+ />
+
+
+
+
+ setEditTarget(null)}>Cancel
+
+ Save
+
+
+
+
+
+ {/* 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)"
+ />
+ )}
+
+
+
+ setCopySource(null)}>Cancel
+
+ Copy
+
+
+
+
+
+ {/* 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.
+
+
+
+ setPendingDelete(null)}>Cancel
+ pendingDelete && handleDelete(pendingDelete)}
+ loading={isLoading}
+ >
+ Delete
+
+
+
+
+
+ );
+};
+
+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}
/>
- setPendingDelete(t)}
- disabled={isLoading}
- />
+ {t.hasCredentials && (
+ setPendingDelete(t)}
+ disabled={isLoading}
+ />
+ )}
))}
@@ -584,30 +589,35 @@ const DepositTargets = (props: Props) => {
- {/* Delete Confirmation Dialog */}
+ {/* Clear Credentials Confirmation Dialog */}
setPendingDelete(null)}
- title="Delete Deposit Target"
+ title="Clear Credentials"
icon="warning-sign"
>
- Delete the deposit target for{' '}
+ Clear credentials for the deposit target on{' '}
{pendingDelete?.communityTitle} (
{pendingDelete?.doiPrefix})?
-
This will remove DOI minting configuration for this community.
+
+ The deposit target will remain but the community will no longer
+ be able to mint DOIs until new credentials are set.
+
setPendingDelete(null)}>Cancel
pendingDelete && handleDelete(pendingDelete)}
+ intent={Intent.WARNING}
+ onClick={() =>
+ pendingDelete && handleClearCredentials(pendingDelete)
+ }
loading={isLoading}
>
- Delete
+ Clear Credentials
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) => {
setPendingDelete(null)}>Cancel
- pendingDelete && handleClearCredentials(pendingDelete)
- }
+ onClick={() => pendingDelete && handleClearCredentials(pendingDelete)}
loading={isLoading}
>
Clear Credentials
diff --git a/server/doi/submit.ts b/server/doi/submit.ts
index d67500a812..bb748ae304 100644
--- a/server/doi/submit.ts
+++ b/server/doi/submit.ts
@@ -68,7 +68,10 @@ export const postToCrossref = async (opts: {
const body = await response.text();
if (!response.ok) {
- const stripped = body.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
+ 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'}`,
From 5cf681ab712f7756562283827abac92b496cd517 Mon Sep 17 00:00:00 2001
From: "Thomas F. K. Jorna"
Date: Thu, 28 May 2026 17:54:26 +0200
Subject: [PATCH 7/7] fix: implement feedback
---
.../DepositTargets/DepositTargets.tsx | 18 +++++++++---------
1 file changed, 9 insertions(+), 9 deletions(-)
diff --git a/client/containers/SuperAdminDashboard/DepositTargets/DepositTargets.tsx b/client/containers/SuperAdminDashboard/DepositTargets/DepositTargets.tsx
index b373de6d5e..092086b57e 100644
--- a/client/containers/SuperAdminDashboard/DepositTargets/DepositTargets.tsx
+++ b/client/containers/SuperAdminDashboard/DepositTargets/DepositTargets.tsx
@@ -100,12 +100,12 @@ const DepositTargets = (props: Props) => {
const [createUsername, setCreateUsername] = useState('');
const [createPassword, setCreatePassword] = useState('');
- // Edit dialog
+ // 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('');
- const [editPassword, setEditPassword] = useState('');
+ const [editUsername, setEditUsername] = useState(undefined);
+ const [editPassword, setEditPassword] = useState(undefined);
// Copy dialog
const [copySource, setCopySource] = useState(null);
@@ -166,8 +166,8 @@ const DepositTargets = (props: Props) => {
setEditTarget(target);
setEditDoiPrefix(target.doiPrefix ?? '');
setEditService((target.service as 'crossref' | 'datacite') ?? 'crossref');
- setEditUsername('');
- setEditPassword('');
+ setEditUsername(undefined);
+ setEditPassword(undefined);
}, []);
const handleEdit = useCallback(async () => {
@@ -180,10 +180,10 @@ const DepositTargets = (props: Props) => {
doiPrefix: editDoiPrefix.trim(),
service: editService,
};
- if (editUsername !== '') {
+ if (editUsername !== undefined) {
body.username = editUsername.trim();
}
- if (editPassword !== '') {
+ if (editPassword !== undefined) {
body.password = editPassword;
}
const result = await apiFetch.put(
@@ -479,7 +479,7 @@ const DepositTargets = (props: Props) => {
>
setEditUsername(e.target.value)}
/>
@@ -495,7 +495,7 @@ const DepositTargets = (props: Props) => {
setEditPassword(e.target.value)}
/>