diff --git a/libraries/overleaf-editor-core/lib/schemas.js b/libraries/overleaf-editor-core/lib/schemas.js index f613e638de..7684cd877b 100644 --- a/libraries/overleaf-editor-core/lib/schemas.js +++ b/libraries/overleaf-editor-core/lib/schemas.js @@ -139,6 +139,10 @@ const rawLinkedFileData = z.discriminatedUnion('provider', [ // default this to 'bibtex' (ZoteroAgent `_getFormat`) format: z.enum(['bibtex', 'biblatex']).optional(), group_id: zz.routeSegment().nullish(), + collection_key: z.union([ + z.literal('publications'), + z.string().regex(/^[A-Za-z0-9]{8}$/), + ]).optional(), importer_id: z.string().optional(), importer_name: z.string().optional(), v1_importer_id: z.number().optional(), diff --git a/services/web/app/src/Features/LinkedFiles/LinkedFilesController.mjs b/services/web/app/src/Features/LinkedFiles/LinkedFilesController.mjs index 9c68858859..ea8c896d09 100644 --- a/services/web/app/src/Features/LinkedFiles/LinkedFilesController.mjs +++ b/services/web/app/src/Features/LinkedFiles/LinkedFilesController.mjs @@ -112,6 +112,10 @@ const createLinkedFileSchema = z.object({ data: z.strictObject({ format: z.enum(['bibtex', 'biblatex']).optional(), group_id: zz.routeSegment().optional(), + collection_key: z.union([ + z.literal('publications'), + z.string().regex(/^[A-Za-z0-9]{8}$/), + ]).optional(), }), }), z.strictObject({ diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json index 0fe4d9620f..4ed000b6bb 100644 --- a/services/web/frontend/extracted-translations.json +++ b/services/web/frontend/extracted-translations.json @@ -160,6 +160,7 @@ "ai_shortcut_on_empty_lines": "", "alignment": "", "all_borders": "", + "all_collections": "", "all_events": "", "all_features_in_group_standard_plus": "", "all_logs": "", @@ -368,6 +369,7 @@ "collabs_per_proj_plus": "", "collabs_per_proj_plus_plural": "", "collapse": "", + "collection": "", "column_width": "", "column_width_is_custom_click_to_resize": "", "column_width_is_x_click_to_resize": "", @@ -1346,6 +1348,7 @@ "move_to_the_left": "", "move_to_the_right": "", "my_library": "", + "my_publications": "", "n_items": "", "n_items_plural": "", "n_more_collaborators": "", @@ -2811,6 +2814,7 @@ "zoom_to": "", "zotero": "", "zotero_account_linked_successfully": "", + "zotero_could_not_load_collections": "", "zotero_dynamic_sync_description": "", "zotero_groups_loading_error": "", "zotero_groups_relink": "", diff --git a/services/web/frontend/js/features/file-view/types/binary-file.ts b/services/web/frontend/js/features/file-view/types/binary-file.ts index 40a27d8e57..e4bd1a5ab0 100644 --- a/services/web/frontend/js/features/file-view/types/binary-file.ts +++ b/services/web/frontend/js/features/file-view/types/binary-file.ts @@ -18,6 +18,7 @@ export type LinkedFileData = { zotero: { provider: 'zotero' group_id: string | null + collection_key?: string | null format: 'bibtex' | 'biblatex' importedAt: Date | string importer_id: string diff --git a/services/web/locales/de.json b/services/web/locales/de.json index 09f798989a..6082d7ae0c 100644 --- a/services/web/locales/de.json +++ b/services/web/locales/de.json @@ -154,6 +154,7 @@ "alignment": "Ausrichtung", "all": "Alle", "all_borders": "Alle Rahmen", + "all_collections": "Alle Sammlungen", "all_events": "Alle Ereignisse", "all_features_in_group_standard_plus": "Alle Funktionen von Group Standard, plus:", "all_logs": "Alle Logs", @@ -333,6 +334,7 @@ "collabs_per_proj": "__count__ Mitarbeiter pro Projekt", "collabs_per_proj_plural": "__count__ Mitarbeiter pro Projekt", "collapse": "Einklappen", + "collection": "Sammlung", "comment": "Kommentar", "comment_only": "Nur Kommentare", "common": "Häufige", @@ -625,6 +627,7 @@ "for_publishers": "Für Verlage", "for_students": "Für Studierende", "for_universities": "Für Universitäten", + "format": "Format", "forgot_your_password": "Passwort vergessen", "found_matching_deleted_users": "__count__ übereinstimmende gelöschte Benutzer gefunden", "fr": "Französisch", @@ -1120,6 +1123,7 @@ "more_than_one_kind_of_snippet_was_requested": "Der Link zum Öffnen dieses Inhalts auf Overleaf enthielt einige ungültige Parameter. Wenn dies bei Links auf einer bestimmten Website weiterhin auftritt, melde dies bitte dort.", "must_be_email_address": "Es muss eine E-Mail-Adresse sein!", "my_library": "Meine Bibliothek", + "my_publications": "Meine Publikationen", "n_items": "__count__ Artikel", "n_items_plural": "__count__ Artikel", "name": "Name", @@ -1933,6 +1937,7 @@ "zip_contents_too_large": "ZIP-Inhalt zu groß", "zotero": "Zotero", "zotero_account_linked_successfully": "Zotero-Konto erfolgreich verknüpft.", + "zotero_could_not_load_collections": "Zotero-Sammlungen konnten nicht geladen werden", "zotero_groups_loading_error": "Beim Laden von Gruppen von Zotero ist ein Fehler aufgetreten", "zotero_groups_relink": "Beim Zugriff auf die Zotero-Daten ist ein Fehler aufgetreten. Dies wurde wahrscheinlich durch fehlende Berechtigungen verursacht. Bitte verknüpfe dein Konto neu und versuche es erneut.", "zotero_integration": "Zotero-Integration", diff --git a/services/web/locales/en.json b/services/web/locales/en.json index 245ce6b9a6..851e484e44 100644 --- a/services/web/locales/en.json +++ b/services/web/locales/en.json @@ -194,6 +194,7 @@ "alignment": "Alignment", "all": "All", "all_borders": "All borders", + "all_collections": "All collections", "all_events": "All events", "all_features_in_group_standard_plus": "All features in Standard group, plus:", "all_logs": "All logs", @@ -460,6 +461,7 @@ "collabs_per_proj_plus": "__count__ collaborator per project, plus:", "collabs_per_proj_plus_plural": "__count__ collaborators per project, plus:", "collapse": "Collapse", + "collection": "Collection", "column_width": "Column width", "column_width_is_custom_click_to_resize": "Column width is custom. Click to resize", "column_width_is_x_click_to_resize": "Column width is __width__. Click to resize", @@ -1785,6 +1787,7 @@ "must_be_email_address": "Must be an email address.", "must_be_purchased_online": "Must be purchased online", "my_library": "My Library", + "my_publications": "My Publications", "n_items": "__count__ item", "n_items_plural": "__count__ items", "n_more_collaborators": "__count__ more collaborator", @@ -3538,6 +3541,7 @@ "zoom_to": "Zoom to", "zotero": "Zotero", "zotero_account_linked_successfully": "Zotero account linked successfully", + "zotero_could_not_load_collections": "Could not load Zotero collections", "zotero_dynamic_sync_description": "With the Zotero integration, you can import your references into __appName__. You can either import all your references at once or dynamically search your Zotero library directly from __appName__.", "zotero_groups_loading_error": "There was an error loading groups from Zotero", "zotero_groups_relink": "There was an error accessing your Zotero data. This was likely caused by lack of permissions. Please re-link your account and try again.", diff --git a/services/web/locales/ru.json b/services/web/locales/ru.json index 75b45aa7ef..29addc9eac 100644 --- a/services/web/locales/ru.json +++ b/services/web/locales/ru.json @@ -70,6 +70,7 @@ "ai_assistant": "ИИ-ассистент", "ai_assistant_and_higher_ai_allowance": "ИИ-ассистент и более высокий лимит ИИ", "ai_assistant_and_max_ai_allowance": "ИИ-ассистент и максимальный лимит ИИ", + "all_collections": "Все коллекции", "all_premium_features": "Все премиум-функции", "all_projects": "Все проекты", "all_templates": "Все шаблоны", @@ -142,6 +143,7 @@ "collaboration": "Совместная разработка", "collaborator": "Соавтор", "collabs_per_proj": "Максимальное число соавторов на проект: __count__", + "collection": "Коллекция", "comment": "Комментарии", "common": "Общие", "commons": "Commons", @@ -249,6 +251,7 @@ "folders": "Папки", "footer_about_us": "О нас", "forgot_your_password": "Забыли пароль?", + "format": "Формат", "found_matching_deleted_users": "Найдено совпадающих удалённых пользователей: __count__", "fr": "Французский", "free": "Бесплатно", @@ -470,6 +473,7 @@ "more_options": "Дополнительные параметры", "must_be_email_address": "Введите правильный адрес электронной почты.", "my_library": "Моя библиотека", + "my_publications": "Мои публикации", "name": "Имя", "native": "браузер", "navigation": "Навигация", @@ -902,6 +906,7 @@ "youve_paused_your_subscription": "Подписка <0>__planName__ приостановлена до <0>__reactivationDate__, затем она возобновится автоматически. Вы можете возобновить её раньше в любой момент.", "zh-CN": "Китайский", "zotero_account_linked_successfully": "Ваш аккаунт Zotero успешно подключён.", + "zotero_could_not_load_collections": "Не удалось загрузить коллекции Zotero", "zotero_groups_loading_error": "При загрузке групп из Zotero произошла ошибка", "zotero_groups_relink": "При загрузке данных из Zotero произошла ошибка, по-видимости вызванная отсутствием разрешения на доступ. Пожалуйста, подключите аккаунт заново и попробуйте ещё раз.", "zotero_reference_loading_error": "Ошибка: не удалось загрузить ссылки из Zotero", diff --git a/services/web/modules/zotero/app/src/AccessTokenEncryptorHelper.mjs b/services/web/modules/zotero/app/src/AccessTokenEncryptorHelper.mjs index d4e563d833..f74301972c 100644 --- a/services/web/modules/zotero/app/src/AccessTokenEncryptorHelper.mjs +++ b/services/web/modules/zotero/app/src/AccessTokenEncryptorHelper.mjs @@ -1,5 +1,4 @@ import logger from '@overleaf/logger' -import Settings from '@overleaf/settings' import fs from 'node:fs' import crypto from 'node:crypto' import Path from 'node:path' @@ -13,7 +12,7 @@ let encryptorInstance = null /** * Get or create a stable encryptor data that persists across container * recreations. Priority: - * 1. ZOTERO_CIPHER_PASSWORD env var (explicit user config) + * 1. ZOTERO_TOKEN_CIPHER_PASSWORD env var (explicit user config) * 2. JSON file with encryptor data in the persistent volume (/var/lib/overleaf/data/) * — auto-generated on first use, survives container rebuilds */ diff --git a/services/web/modules/zotero/app/src/ZoteroApiClient.mjs b/services/web/modules/zotero/app/src/ZoteroApiClient.mjs index 44541449b8..812a9b3783 100644 --- a/services/web/modules/zotero/app/src/ZoteroApiClient.mjs +++ b/services/web/modules/zotero/app/src/ZoteroApiClient.mjs @@ -1,10 +1,8 @@ import logger from '@overleaf/logger' import OError from '@overleaf/o-error' -import AbortError from 'node-fetch' import { fetchNothing, fetchJson, - fetchString, fetchStringWithResponse, RequestFailedError, } from '@overleaf/fetch-utils' @@ -61,38 +59,107 @@ async function getGroupsForUser(userId) { if (!credentials) return null const { apiKey, zoteroUserId } = credentials + const groups = [] + const limit = 100 + try { - const groups = await fetchJson(`${ZOTERO_API_URL}/users/${zoteroUserId}/groups`, { - headers: buildHeaders(apiKey), - signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) - }) - return groups.map(g => ({ - id: String(g.id), - name: g.data?.name || `Group ${g.id}`, - })) + for (let start = 0; ; start += limit) { + const page = await fetchJson(`${ZOTERO_API_URL}/users/${zoteroUserId}/groups?limit=${limit}&start=${start}`, { + headers: buildHeaders(apiKey), + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }) + groups.push(...page.map(g => ({ + id: String(g.id), + name: g.data?.name || `Group ${g.id}`, + }))) + if (page.length < limit) break + } + return groups + } catch (err) { normalizeApiError(err, 'getGroupsForUser') } } /** - * Export a library as BibTeX / BibLaTeX. + * Return the Zotero path for a personal or group library. + * Identifiers are validated before they become URL path components. */ -async function getLibraryBibtex(userId, groupId, format) { +function getLibraryPath(zoteroUserId, groupId) { + const id = groupId || zoteroUserId + if (!/^\d+$/.test(String(id))) { + throw new OError('Invalid Zotero library ID', { status: 400 }) + } + return groupId ? `/groups/${id}` : `/users/${id}` +} + +/** + * List all collections, including nested collections, in a library. + */ +async function getCollectionsForLibrary(userId, groupId) { + const credentials = await TokenManager.getCredentials(userId) + if (!credentials) return null + + const libraryPath = getLibraryPath(credentials.zoteroUserId, groupId) + const collections = [] + const limit = 100 + + try { + for (let start = 0; ; start += limit) { + const page = await fetchJson( + `${ZOTERO_API_URL}${libraryPath}/collections?limit=${limit}&start=${start}`, + { + headers: buildHeaders(credentials.apiKey), + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + } + ) + if (!Array.isArray(page)) throw new OError('Invalid Zotero collections response', { status: 500 }) + + collections.push(...page.map(collection => ({ + key: collection.key, + name: collection.data.name, + parentCollection: collection.data.parentCollection || null, + }))) + if (page.length < limit) break + } + } catch (err) { + normalizeApiError(err, 'getCollectionsForLibrary') + } + + return collections +} + +/** + * Export a library, collection, or personal My Publications view as BibTeX / BibLaTeX. + */ +async function getLibraryBibtex(userId, groupId, format, collectionKey) { const credentials = await TokenManager.getCredentials(userId) if (!credentials) { throw new ServiceNotConfiguredError({ - message: 'RefProvider credentials missed', + message: 'RefProvider credentials missing', info: { userId, status: 400 } }) } - // Main library or group? - let basePath - if (groupId) basePath = `/groups/${groupId}/items` - else basePath = `/users/${credentials.zoteroUserId}/items` + const libraryPath = getLibraryPath(credentials.zoteroUserId, groupId) + // "publications" is a local selector value, not a Zotero collection key. + const isPublications = collectionKey === 'publications' + if (isPublications && groupId) { + throw new OError('My Publications is only available in personal libraries', { status: 400 }) + } + if (collectionKey != null && !isPublications && + (typeof collectionKey !== 'string' || !/^[A-Za-z0-9]{8}$/.test(collectionKey))) { + throw new OError('Invalid Zotero collection key', { status: 400 }) + } - return _fetchBibtex(credentials.apiKey, basePath, format) + let basePath = `${libraryPath}/items` + if (isPublications) { + basePath = `${libraryPath}/publications/items` + } else if (collectionKey) { + basePath = `${libraryPath}/collections/${collectionKey}/items` + } + + return _fetchBibtex(credentials.apiKey, basePath, format === 'biblatex' ? 'biblatex' : 'bibtex') } /** @@ -107,7 +174,7 @@ async function _fetchBibtex(apiKey, basePath, format) { let start = 0 const headers = buildHeaders(apiKey) while (true) { - const url = `${ZOTERO_API_URL}${basePath}?format=${format}&limit=${limit}&start=${start}` + const url = `${ZOTERO_API_URL}${basePath}?format=${format}&limit=${limit}&start=${start}&includeTrashed=0` const { body: bibtexFetched, response } = await fetchStringWithResponse(url, { headers, signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) @@ -116,7 +183,12 @@ async function _fetchBibtex(apiKey, basePath, format) { if (bibtex.trim()) allBibtex += bibtex - const totalResults = parseInt(response.headers.get('Total-Results') || '0', 10) + const totalResultsHeader = response.headers.get('Total-Results') + const totalResults = Number(totalResultsHeader) + if (totalResultsHeader === null || !/^\d+$/.test(totalResultsHeader) || + !Number.isSafeInteger(totalResults)) { + throw new OError('Missing or invalid Zotero Total-Results header', { status: 500 }) + } start += limit if (start >= totalResults) { break @@ -133,13 +205,14 @@ async function _fetchBibtex(apiKey, basePath, format) { */ async function unlinkAccount(userId) { const credentials = await TokenManager.getCredentials(userId) - if (!credentials) return await User.updateOne( { _id: userId }, { $unset: { 'refProviders.zotero': 1 } } ).exec() + if (!credentials) return + try { const { apiKey } = credentials await fetchNothing(`${ZOTERO_API_URL}/keys/${apiKey}`, { @@ -148,9 +221,8 @@ async function unlinkAccount(userId) { signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), }) } catch (err) { - logger.error({ err }, 'failed to detete key from Zotero account') + logger.error({ err }, 'failed to delete key from Zotero account') } - } function normalizeApiError(err, operation) { @@ -182,7 +254,7 @@ function normalizeApiError(err, operation) { if (status === 429) { throw new TooManyRequestsError({ - message: 'Rate limit exeeded', + message: 'Rate limit exceeded', info: { operation, status } }).withCause(err) } @@ -193,6 +265,7 @@ function normalizeApiError(err, operation) { export default { getConnectionStatus, getGroupsForUser, + getCollectionsForLibrary, getLibraryBibtex, unlinkAccount, } diff --git a/services/web/modules/zotero/app/src/ZoteroController.mjs b/services/web/modules/zotero/app/src/ZoteroController.mjs index c65a16c52d..c1cb62fd95 100644 --- a/services/web/modules/zotero/app/src/ZoteroController.mjs +++ b/services/web/modules/zotero/app/src/ZoteroController.mjs @@ -13,6 +13,7 @@ async function oauth(req, res) { const isPopup = req.query.popup === '1' req.session.zoteroOAuth = { + userId: String(SessionManager.getLoggedInUserId(req.session)), token: requestToken.oauth_token, tokenSecret: requestToken.oauth_token_secret, isPopup @@ -42,7 +43,7 @@ async function oauthCallback(req, res) { const saved = req.session.zoteroOAuth delete req.session.zoteroOAuth - if (!saved || saved.token !== oauthToken) { + if (!saved || saved.token !== oauthToken || saved.userId !== String(userId)) { HttpErrorHandler.forbidden(req, res, 'Invalid OAuth token') return } @@ -58,19 +59,28 @@ async function oauthCallback(req, res) { } catch (err) { logger.error(OError.getFullStack(err)) const info = OError.getFullInfo(err) - logger.error({ info }, "Failed to obtain Zotero access token'") + logger.error({ info }, 'Failed to obtain Zotero access token') HttpErrorHandler.badRequest(req, res, 'Failed to obtain Zotero access token') return } const nonce = crypto.randomBytes(16).toString('base64') - const csp = res.getHeader('Content-Security-Policy') + const scriptNonce = `'nonce-${nonce}'` + const directives = String(res.getHeader('Content-Security-Policy') || '') + .split(';') + .map(directive => directive.trim()) + .filter(Boolean) - res.setHeader( - 'Content-Security-Policy', - `${csp}; script-src 'nonce-${nonce}'` - ) + let hasScriptDirective = false + for (let i = 0; i < directives.length; i++) { + if (/^script-src(?:-elem)?(?:\s|$)/i.test(directives[i])) { + directives[i] += ` ${scriptNonce}` + hasScriptDirective = true + } + } + if (!hasScriptDirective) directives.push(`script-src ${scriptNonce}`) + res.setHeader('Content-Security-Policy', directives.join('; ')) res.send(` @@ -128,7 +138,28 @@ async function getGroups(req, res) { } /** - * DELETE /user/zotero/unlink + * GET /user/zotero/collections?group_id= + * Returns all collections in the personal library or selected group. + */ +async function getCollections(req, res) { + const userId = SessionManager.getLoggedInUserId(req.session) + try { + const collections = await ZoteroApiClient.getCollectionsForLibrary( + userId, + req.query.group_id + ) + res.json(collections) + } catch (err) { + const info = OError.getFullInfo(err) + const errStatus = info?.status || 500 + logger.error(OError.getFullStack(err)) + logger.error({ info }, 'failed to get Zotero collections') + return res.status(errStatus).json({ message: err.message }) + } +} + +/** + * DELETE /user/zotero * Unlinks the user's Zotero account. */ async function unlink(req, res) { @@ -150,5 +181,6 @@ export default { oauthCallback, getConnectionStatus, getGroups, + getCollections, unlink } diff --git a/services/web/modules/zotero/app/src/ZoteroLinkedFileAgent.mjs b/services/web/modules/zotero/app/src/ZoteroLinkedFileAgent.mjs index 6c595a669e..1247fa48f7 100644 --- a/services/web/modules/zotero/app/src/ZoteroLinkedFileAgent.mjs +++ b/services/web/modules/zotero/app/src/ZoteroLinkedFileAgent.mjs @@ -1,11 +1,8 @@ import logger from '@overleaf/logger' import { callbackify } from '@overleaf/promise-utils' -import { Project } from '../../../../app/src/models/Project.mjs' -import ProjectLocator from '../../../../app/src/Features/Project/ProjectLocator.mjs' import UserGetter from '../../../../app/src/Features/User/UserGetter.mjs' import { NotFoundError, - TooManyRequestsError, ServiceNotConfiguredError, ForbiddenError } from '../../../../app/src/Features/Errors/Errors.js' @@ -20,6 +17,7 @@ import ZoteroApiClient from './ZoteroApiClient.mjs' * { * provider: 'zotero' * group_id: string | null + * collection_key?: string | null * importedAt: Date | string * importer_id: string * importer_name: string @@ -28,6 +26,9 @@ import ZoteroApiClient from './ZoteroApiClient.mjs' * * - If group_id is present, export that group's library. * - Otherwise, export the user's personal library ("My Library"). + * - If collection_key is present, export only items in that collection. + * - The reserved collection_key "publications" exports My Publications. + * - Trash is excluded from all exports. */ async function createLinkedFile( projectId, @@ -45,17 +46,7 @@ async function createLinkedFile( 'creating Zotero linked file' ) - const bibtex = await _getBibtex(linkedFileData) - - const file = await LinkedFilesHandler.promises.importContent( - projectId, - bibtex, - _sanitizeData(linkedFileData), - name, - parentFolderId, - userId - ) - return file._id + return _importLinkedFile(projectId, linkedFileData, name, parentFolderId, userId) } /** @@ -73,22 +64,17 @@ async function refreshLinkedFile( 'refreshing Zotero linked file' ) -// refresh importer's displayed name -// if the importer is the owner, name is not displayed, refresh is not needed -// if the importer is not available, the old name is preserved + // Preserve the old name if the importer is unavailable. Persist the refreshed + // name with the bibliography through importContent(), not a separate write. const userName = await _getUserName(linkedFileData.importer_id) - if (userName && linkedFileData.importer_id != userId) { + if (userName) { linkedFileData.importer_name = userName - const { element, path } = await ProjectLocator.promises.findElement({ - project_id: projectId, - element_id: parentFolderId, - type: 'folders' - }) - const fileIndex = element.fileRefs.findIndex(file => file.name === name) - const updatePath = `${path.mongo}.fileRefs.${fileIndex}.linkedFileData.importer_name` - await Project.updateOne({ _id: projectId }, { $set: { [updatePath]: userName } }) } + return _importLinkedFile(projectId, linkedFileData, name, parentFolderId, userId) +} + +async function _importLinkedFile(projectId, linkedFileData, name, parentFolderId, userId) { const bibtex = await _getBibtex(linkedFileData) const file = await LinkedFilesHandler.promises.importContent( @@ -108,7 +94,8 @@ async function _getBibtex(linkedFileData) { return await ZoteroApiClient.getLibraryBibtex( userId, linkedFileData.group_id, // == null for main library - linkedFileData.format || 'bibtex' + linkedFileData.format || 'bibtex', + linkedFileData.collection_key ) } catch (err) { @@ -121,8 +108,8 @@ async function _getBibtex(linkedFileData) { throw new LinkedFilesErrors.AccessDeniedError('Zotero account not linked').withCause(err) } if (err instanceof NotFoundError) { - logger.debug({ group: linkedFileData.group_id, err }, 'Zotero group is not found') - throw new LinkedFilesErrors.SourceFileNotFoundError('Zotero group is not found').withCause(err) + logger.debug({ group: linkedFileData.group_id, collection: linkedFileData.collection_key, err }, 'Zotero library or collection is not found') + throw new LinkedFilesErrors.SourceFileNotFoundError('Zotero library or collection is not found').withCause(err) } logger.error({ linkedFileData, err }, 'failed to retrieve bib file from Zotero') throw new LinkedFilesErrors.RemoteServiceError('Error retrieving bib file from Zotero').withCause(err) @@ -133,6 +120,9 @@ function _sanitizeData(data) { return { provider: 'zotero', group_id: data.group_id ?? null, + ...(data.collection_key && { + collection_key: data.collection_key, + }), importedAt: data.importedAt, ...(data.importer_id && { importer_id: data.importer_id, diff --git a/services/web/modules/zotero/app/src/ZoteroOAuth.mjs b/services/web/modules/zotero/app/src/ZoteroOAuth.mjs index 3be131e960..b738ab7040 100644 --- a/services/web/modules/zotero/app/src/ZoteroOAuth.mjs +++ b/services/web/modules/zotero/app/src/ZoteroOAuth.mjs @@ -31,8 +31,12 @@ function buildAuthorizationHeader(requestData, tokenData) { return oauth.toHeader(authData).Authorization } -function parseOAuthResponse(body) { - return Object.fromEntries(new URLSearchParams(body).entries()) +function parseOAuthResponse(body, requiredFields) { + const data = Object.fromEntries(new URLSearchParams(body).entries()) + if (requiredFields.some(field => !data[field]?.trim())) { + throw new OError('Invalid Zotero OAuth response') + } + return data } function getAuthorizationUrl(oauthToken) { @@ -44,11 +48,6 @@ function getAuthorizationUrl(oauthToken) { return `${ZOTERO_OAUTH_URL}/authorize?${params}` } -/* -function getAuthorizationUrl(oauthToken) { - return `${ZOTERO_OAUTH_URL}/authorize?oauth_token=${oauthToken}` -} -*/ async function getRequestToken() { const url = `${ZOTERO_OAUTH_URL}/request` @@ -67,7 +66,7 @@ async function getRequestToken() { }, }) - return parseOAuthResponse(body) + return parseOAuthResponse(body, ['oauth_token', 'oauth_token_secret']) } catch (err) { throw OError.tag(err, 'error obtaining Zotero request token') } @@ -100,16 +99,17 @@ async function exchangeRequestTokenForAccessToken( }, }) - const data = parseOAuthResponse(body) + const data = parseOAuthResponse(body, ['oauth_token', 'userID']) + if (!/^\d+$/.test(data.userID)) { + throw new OError('Invalid Zotero user ID in OAuth response') + } return { accessToken: data.oauth_token, - apiKeySecret: data.oauth_token_secret, zoteroUserId: data.userID, - username: data.username, } } catch (err) { - logger.err( + logger.error( { err, oauthToken }, 'error obtaining Zotero access token' ) diff --git a/services/web/modules/zotero/app/src/ZoteroRouter.mjs b/services/web/modules/zotero/app/src/ZoteroRouter.mjs index f09f54b2d8..7939e64554 100644 --- a/services/web/modules/zotero/app/src/ZoteroRouter.mjs +++ b/services/web/modules/zotero/app/src/ZoteroRouter.mjs @@ -10,6 +10,12 @@ export default { ZoteroController.getGroups ) + webRouter.get( + '/user/zotero/collections', + AuthenticationController.requireLogin(), + ZoteroController.getCollections + ) + // Unlink Zotero account webRouter.delete( '/user/zotero', diff --git a/services/web/modules/zotero/frontend/js/components/file-tree-import-from-zotero.tsx b/services/web/modules/zotero/frontend/js/components/file-tree-import-from-zotero.tsx index 4aca5a4cde..c079e8400e 100644 --- a/services/web/modules/zotero/frontend/js/components/file-tree-import-from-zotero.tsx +++ b/services/web/modules/zotero/frontend/js/components/file-tree-import-from-zotero.tsx @@ -1,5 +1,8 @@ import { useTranslation } from 'react-i18next' -import { FormEventHandler, useEffect, useState } from 'react' +import { FormEventHandler, useEffect, useMemo, useState } from 'react' +import { getJSON } from '@/infrastructure/fetch-json' +import useAsync from '@/shared/hooks/use-async' +import { debugConsole } from '@/utils/debugging' import * as eventTracking from '@/infrastructure/event-tracking' import { useFileTreeActionable } from '@/features/file-tree/contexts/file-tree-actionable' import { useFileTreeCreateForm } from '@/features/file-tree/contexts/file-tree-create-form' @@ -11,24 +14,71 @@ import OLFormLabel from '@/shared/components/ol/ol-form-label' import OLFormSelect from '@/shared/components/ol/ol-form-select' import Notification from '@/shared/components/notification' -type ZoteroGroup = { +export type ZoteroGroup = { id: string name: string } +type ZoteroCollection = { + key: string + name: string + parentCollection: string | null +} + +function collectionOptions(collections: ZoteroCollection[]) { + const byKey = new Map(collections.map(c => [c.key, c])) + return collections.map(collection => { + const path = [collection.name] + const visited = new Set([collection.key]) + let parentKey = collection.parentCollection + while (parentKey && !visited.has(parentKey)) { + const parent = byKey.get(parentKey) + if (!parent) break + path.unshift(parent.name) + visited.add(parentKey) + parentKey = parent.parentCollection + } + return { key: collection.key, label: path.join(' / ') } + }).sort((a, b) => a.label.localeCompare(b.label)) +} + type FileTreeImportFromZoteroProps = { groups: ZoteroGroup[] } export default function FileTreeImportFromZotero({ groups }: FileTreeImportFromZoteroProps) { const { t } = useTranslation() - const { name, setName } = useFileTreeCreateName() + const { name } = useFileTreeCreateName() const { setValid } = useFileTreeCreateForm() const { finishCreatingLinkedFile, error, inFlight } = useFileTreeActionable() - const [selectedGroupId, setSelectedGroupId] = useState("") - const [selectedFormat, setSelectedFormat] = useState('bibtex') + const [selectedGroupId, setSelectedGroupId] = useState('') + const [selectedCollectionKey, setSelectedCollectionKey] = useState('') + const { + runAsync, + reset, + data: collections, + isLoading: collectionsLoading, + isError: collectionsError, + } = useAsync({ status: 'pending' }) + const [selectedFormat, setSelectedFormat] = useState('bibtex') + const sortedCollections = useMemo(() => collectionOptions(collections || []), [collections]) + + useEffect(() => { + const controller = new AbortController() + reset() + + const query = selectedGroupId ? `?group_id=${encodeURIComponent(selectedGroupId)}` : '' + runAsync(getJSON(`/user/zotero/collections${query}`, { + signal: controller.signal, + })) + .catch(err => { + debugConsole.error(err?.data?.message || err?.message || err) + }) + + return () => controller.abort() + }, [selectedGroupId, runAsync, reset]) useEffect(() => { setValid(!!name) - }, [name]) + }, [name, setValid]) const handleSubmit: FormEventHandler = event => { event.preventDefault() @@ -42,6 +92,7 @@ export default function FileTreeImportFromZotero({ groups }: FileTreeImportFromZ provider: 'zotero', data: { group_id: selectedGroupId || undefined, + collection_key: selectedCollectionKey || undefined, format: selectedFormat, } }) @@ -62,9 +113,10 @@ export default function FileTreeImportFromZotero({ groups }: FileTreeImportFromZ id="zotero-library-select" value={selectedGroupId} disabled={inFlight} - onChange={(e: React.ChangeEvent) => + onChange={(e: React.ChangeEvent) => { setSelectedGroupId(e.target.value) - } + setSelectedCollectionKey('') + }} > {groups?.map(g => ( @@ -75,6 +127,32 @@ export default function FileTreeImportFromZotero({ groups }: FileTreeImportFromZ + + {t('collection')} + ) => + setSelectedCollectionKey(e.target.value) + } + > + + {!selectedGroupId && ( + + )} + {sortedCollections.map(collection => ( + + ))} + + + + {collectionsError && ( + + )} +   - {(importer_id === getMeta('ol-user_id')) ? ( + {(importerId === getMeta('ol-user_id')) ? ( t('imported_from_zotero_at_date', { formattedDate, relativeDate: relative, @@ -41,7 +45,7 @@ export function TPRFileViewInfo({ file }: TPRFileViewInfoProps) { t('imported_from_zotero_at_date_by', { formattedDate, relativeDate: relative, - importer_name, + importedByName: importerName, }) )}

diff --git a/services/web/modules/zotero/frontend/js/components/tpr-file-view-not-original-importer.tsx b/services/web/modules/zotero/frontend/js/components/tpr-file-view-not-original-importer.tsx index ee71c9d4d0..58f106a639 100644 --- a/services/web/modules/zotero/frontend/js/components/tpr-file-view-not-original-importer.tsx +++ b/services/web/modules/zotero/frontend/js/components/tpr-file-view-not-original-importer.tsx @@ -1,12 +1,11 @@ import type { LinkedFile, LinkedFileData } from '@/features/file-view/types/binary-file' -type TPRFileViewInfoProps = { +type TPRFileViewNotOriginalImporterProps = { file: LinkedFile } -export function TPRFileViewNotOriginalImporter({ - file, -}: TPRFileViewNotOriginalImporterProps) { +// Keep the exported hook until its host registration can be removed together. +export function TPRFileViewNotOriginalImporter(_props: TPRFileViewNotOriginalImporterProps) { return null } diff --git a/services/web/modules/zotero/frontend/js/components/zotero-create-file.tsx b/services/web/modules/zotero/frontend/js/components/zotero-create-file.tsx index 8936cd6641..a3f6762d07 100644 --- a/services/web/modules/zotero/frontend/js/components/zotero-create-file.tsx +++ b/services/web/modules/zotero/frontend/js/components/zotero-create-file.tsx @@ -8,7 +8,7 @@ import Notification from '@/shared/components/notification' import { useFileTreeActionable } from '@/features/file-tree/contexts/file-tree-actionable' import FileTreeModalCreateFileMode from '@/features/file-tree/components/file-tree-create/file-tree-modal-create-file-mode' import FileTreeCreateNameProvider from '@/features/file-tree/contexts/file-tree-create-name' -import FileTreeImportFromZotero from './file-tree-import-from-zotero' +import FileTreeImportFromZotero, { type ZoteroGroup } from './file-tree-import-from-zotero' export function CreateFileMode() { const { t } = useTranslation() @@ -93,11 +93,12 @@ export function CreateFilePane() { { - window.open( + const popup = window.open( '/user/zotero/oauth?popup=1', '_blank', 'width=600,height=700' ) + if (!popup) window.location.assign('/user/zotero/oauth?popup=0') }} > {t('link_to_zotero')} diff --git a/services/web/modules/zotero/frontend/js/components/zotero-integration-card.tsx b/services/web/modules/zotero/frontend/js/components/zotero-integration-card.tsx index 961d2462d6..e2cf47f6b1 100644 --- a/services/web/modules/zotero/frontend/js/components/zotero-integration-card.tsx +++ b/services/web/modules/zotero/frontend/js/components/zotero-integration-card.tsx @@ -15,20 +15,22 @@ const ZoteroLinkCard = () => { const { isError, runAsync, - data: isConnected + isLoading } = useAsync() const handleClick = () => { + if (isLoading) return runAsync(getJSON('/user/zotero/status')) .then(isConnected => { if (isConnected) { setShowModal(true) } else { - window.open( + const popup = window.open( '/user/zotero/oauth?popup=1', '_blank', 'width=600,height=700' ) + if (!popup) window.location.assign('/user/zotero/oauth?popup=0') } }) .catch(err => { diff --git a/services/web/modules/zotero/frontend/js/components/zotero-link-info-modal.tsx b/services/web/modules/zotero/frontend/js/components/zotero-link-info-modal.tsx index 0e8c6c7c0a..9708640276 100644 --- a/services/web/modules/zotero/frontend/js/components/zotero-link-info-modal.tsx +++ b/services/web/modules/zotero/frontend/js/components/zotero-link-info-modal.tsx @@ -2,7 +2,7 @@ import { useTranslation, Trans } from 'react-i18next' import useAsync from '@/shared/hooks/use-async' import { deleteJSON } from '@/infrastructure/fetch-json' import { debugConsole } from '@/utils/debugging' -import { useState, useEffect, useCallback } from 'react' +import { useState, useEffect } from 'react' import { OLModal, OLModalHeader, @@ -13,11 +13,9 @@ import { import OLButton from '@/shared/components/ol/ol-button' import Notification from '@/shared/components/notification' -import { ProjectSyncState, GitSyncModalStatus } from '../../types/git-sync-types' - -type ZoteroLinkInfoModalModalProps = { +type ZoteroLinkInfoModalProps = { show: boolean - isError: any + isError: boolean handleHide: () => void } @@ -29,18 +27,20 @@ const ZoteroLinkInfoModal = ({ show, isError, handleHide }: ZoteroLinkInfoModalP isLoading: isUnlinking, isError: isErrorUnlink, runAsync: runAsyncUnlink, + reset: resetUnlink, } = useAsync() const handleUnlink = () => { runAsyncUnlink(deleteJSON('/user/zotero')) + .then(handleHide) .catch(err => debugConsole.error(err?.data?.message || err?.message || err)) - handleHide() } useEffect(() => { if (!show) return setShowUnlinkInfo(false) - }, [show]) + resetUnlink() + }, [show, resetUnlink]) return ( @@ -49,6 +49,9 @@ const ZoteroLinkInfoModal = ({ show, isError, handleHide }: ZoteroLinkInfoModalP + {isErrorUnlink && ( + + )} {showUnlinkInfo ? ( void - /** Host writes these BibTeX entries (raw text, combined). */ - onInsert: (bibtexText: string) => void -} - -type Step = 'library' | 'items' - -function errorMessage(err: unknown, fallback: string) { - if (err && typeof err === 'object') { - const data = (err as { data?: { message?: string } }).data - if (data?.message) return data.message === 'zotero_not_linked' ? 'zotero_not_linked' : data.message - if ((err as { message?: string }).message) { - return (err as { message: string }).message - } - } - return fallback -} - -const ITEMS_PER_PAGE = 100 - -export default function ZoteroPickerModal({ - show, - handleHide, - onInsert, -}: ZoteroPickerModalProps) { - const { t } = useTranslation() - - const [step, setStep] = useState('library') - const [notLinked, setNotLinked] = useState(false) - - // -- library step -- - const [loadingLibraries, setLoadingLibraries] = useState(false) - const [libraries, setLibraries] = useState([]) - const [libraryIdx, setLibraryIdx] = useState(0) - const [collections, setCollections] = useState([]) - const [collectionsLoading, setCollectionsLoading] = useState(false) - const [collectionKey, setCollectionKey] = useState('') - const [libraryError, setLibraryError] = useState(null) - - // -- items step -- - const [items, setItems] = useState([]) - const [total, setTotal] = useState(0) - const [itemsLoading, setItemsLoading] = useState(false) - const [itemsError, setItemsError] = useState(null) - const [selectedKeys, setSelectedKeys] = useState>(new Set()) - - // -- import state -- - const [importing, setImporting] = useState(false) - const [importError, setImportError] = useState(null) - - const currentLibrary = libraries[libraryIdx] - - // ----------------------------------------------------------------------- - // Reset when the modal closes - // ----------------------------------------------------------------------- - const handleClose = useCallback(() => { - setStep('library') - setNotLinked(false) - setLibraries([]) - setLibraryIdx(0) - setCollections([]) - setCollectionKey('') - setLibraryError(null) - setItems([]) - setTotal(0) - setItemsError(null) - setSelectedKeys(new Set()) - setImporting(false) - setImportError(null) - handleHide() - }, [handleHide]) - - // ----------------------------------------------------------------------- - // Load libraries when the modal opens - // ----------------------------------------------------------------------- - useEffect(() => { - if (!show) return - let alive = true - setLoadingLibraries(true) - setLibraryError(null) - setNotLinked(false) - setLibraries([]) - setCollections([]) - setCollectionKey('') - setLibraryIdx(0) - void getJSON('/user/zotero/picker/libraries') - .then(data => { - if (!alive) return - const libs = Array.isArray(data) ? data : [] - setLibraries(libs) - if (libs.length === 0) { - setLibraryError(t('Failed to load your Zotero libraries')) - } - }) - .catch(err => { - if (!alive) return - const msg = errorMessage(err, t('Failed to load your Zotero libraries')) - if (msg === 'zotero_not_linked') setNotLinked(true) - else setLibraryError(msg) - }) - .finally(() => { - if (alive) setLoadingLibraries(false) - }) - return () => { - alive = false - } - }, [show, t]) - - // ----------------------------------------------------------------------- - // Load the collections of the selected library - // ----------------------------------------------------------------------- - const loadCollections = useCallback( - (lib: Library) => { - setCollections([]) - setCollectionKey('') - setCollectionsLoading(true) - const qs = new URLSearchParams({ libraryKind: lib.kind }) - if (lib.kind === 'group') qs.set('library', lib.id) - void getJSON(`/user/zotero/picker/collections?${qs.toString()}`) - .then(data => { - setCollections(Array.isArray(data) ? data : []) - }) - .catch(err => { - const msg = errorMessage(err, null) - if (msg === 'zotero_not_linked') setNotLinked(true) - }) - .finally(() => setCollectionsLoading(false)) - }, - [] - ) - - useEffect(() => { - if (show && currentLibrary) { - loadCollections(currentLibrary) - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [show, libraryIdx, libraries.length]) - - // ----------------------------------------------------------------------- - // Browse items - // ----------------------------------------------------------------------- - const browse = useCallback(() => { - if (!currentLibrary) return - setStep('items') - setItemsError(null) - setImportError(null) - setItems([]) - setSelectedKeys(new Set()) - setItemsLoading(true) - const qs = new URLSearchParams({ - libraryKind: currentLibrary.kind, - }) - if (currentLibrary.kind === 'group') qs.set('library', currentLibrary.id) - if (collectionKey) qs.set('collection', collectionKey) - qs.set('limit', String(ITEMS_PER_PAGE)) - void getJSON<{ items: ZItem[]; total: number }>( - `/user/zotero/picker/items?${qs.toString()}` - ) - .then(data => { - setItems(data.items || []) - setTotal(data.total || (data.items || []).length) - }) - .catch(err => { - const msg = errorMessage(err, t('Failed to load items')) - if (msg === 'zotero_not_linked') { - setNotLinked(true) - setStep('library') - } else { - setItemsError(msg) - } - }) - .finally(() => setItemsLoading(false)) - }, [currentLibrary, collectionKey, t]) - - // ----------------------------------------------------------------------- - // Selection - // ----------------------------------------------------------------------- - const toggleItem = useCallback((key: string) => { - setSelectedKeys(prev => { - const next = new Set(prev) - if (next.has(key)) next.delete(key) - else next.add(key) - return next - }) - }, []) - - const allSelected = items.length > 0 && items.every(i => selectedKeys.has(i.key)) - const toggleAll = useCallback(() => { - if (allSelected) setSelectedKeys(new Set()) - else setSelectedKeys(new Set(items.map(i => i.key))) - }, [allSelected, items]) - - // ----------------------------------------------------------------------- - // Import - // ----------------------------------------------------------------------- - const handleImport = useCallback(async () => { - if (!currentLibrary || selectedKeys.size === 0) return - setImporting(true) - setImportError(null) - try { - const qs = new URLSearchParams({ - libraryKind: currentLibrary.kind, - keys: Array.from(selectedKeys).join(','), - }) - if (currentLibrary.kind === 'group') qs.set('library', currentLibrary.id) - const data = await getJSON<{ bibtex: string }>( - `/user/zotero/picker/bibtex?${qs.toString()}` - ) - onInsert(data.bibtex || '') - handleClose() - } catch (err) { - const msg = errorMessage(err, null) - if (msg === 'zotero_not_linked') { - setNotLinked(true) - setImportError(t('Zotero is no longer linked to your account')) - } else { - setImportError(errorMessage(err, t('Could not import from Zotero'))) - } - setImporting(false) - } - }, [currentLibrary, selectedKeys, onInsert, handleClose, t]) - - // ----------------------------------------------------------------------- - // Render - // ----------------------------------------------------------------------- - return ( - - - - {step === 'library' - ? t('Import from Zotero') - : t('Items', { - author: currentLibrary?.name || t('Zotero'), - })} - - - - - {notLinked ? ( - <> -

{t('Zotero is not linked to your account yet.')}

-

- {t('Link your Zotero account to import references. Create a key at ') + - ( - - zotero.org/settings/keys - - ) + - t(' (library access + read access to all groups).')} -

-
- - {t('Link Zotero')} - - - {t('Cancel')} - -
- - ) : ( - <> - {step === 'library' && ( - <> - {libraryError && ( - setLibraryError(null)} - /> - )} - {loadingLibraries &&

{t('Loading your Zotero libraries…')}

} - {!loadingLibraries && libraries.length > 0 && ( - <> - - {t('library')} - ) => - setLibraryIdx(Number(e.target.value) || 0) - } - > - {libraries.map((lib, i) => ( - - ))} - - - - - {t('collection')} ({t('optional')}) - - ) => - setCollectionKey(e.target.value) - } - > - - {collections.map(c => ( - - ))} - - - - {t('Browse items')} - - - )} - - )} - - {step === 'items' && ( - <> - { - setStep('library') - setItemsError(null) - setImportError(null) - }} - style={{ padding: 0, marginBottom: '8px' }} - data-testid="zot-back" - > - {t('Back to libraries')} - - - {itemsLoading &&

{t('Loading items…')}

} - - {itemsError && ( - setItemsError(null)} - /> - )} - {importError && ( - setImportError(null)} - /> - )} - - {!itemsLoading && items.length > 0 && ( - <> -

- {t('Showing __shown__ of __total__ (newest first)', { - shown: items.length, - total, - })} -

-
- -
-
- - - - - - - - - - {items.map((w, i) => ( - - - - - - - ))} - -
- - {t('Title')} - - {t('Type')} - - {t('Year')} -
- toggleItem(w.key)} - aria-label={w.title || t('Untitled')} - /> - - {w.title || t('Untitled')} - {w.firstCreator ? ( - - {` — ${w.firstCreator}`} - - ) : null} - - {w.itemType || '—'} - - {(w.date || '').slice(0, 4) || '—'} -
-
- - )} - - )} - - )} -
- - {step === 'items' && !notLinked && !itemsLoading && items.length > 0 && ( - - - {t('Cancel')} - - void handleImport()} - disabled={selectedKeys.size === 0 || importing} - isLoading={importing} - > - {t('Import selected (__count__)', { count: selectedKeys.size })} - - - )} -
- ) -} diff --git a/services/web/modules/zotero/frontend/js/components/zotero-widget.tsx b/services/web/modules/zotero/frontend/js/components/zotero-widget.tsx index 537e7d77dc..e382b83157 100644 --- a/services/web/modules/zotero/frontend/js/components/zotero-widget.tsx +++ b/services/web/modules/zotero/frontend/js/components/zotero-widget.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import getMeta from '@/utils/meta' -import { getJSON, deleteJSON } from '@/infrastructure/fetch-json' +import { getJSON, deleteJSON, isForbidden, type FetchError } from '@/infrastructure/fetch-json' import useAsync from '@/shared/hooks/use-async' import { debugConsole } from '@/utils/debugging' import OLButton from '@/shared/components/ol/ol-button' @@ -17,10 +17,7 @@ import ZoteroLogo from '@/shared/svgs/zotero-logo' /** * Zotero account linking widget for the Account Settings page. - * Instead of OAuth, users paste their Zotero API key directly. - * Create one at https://www.zotero.org/settings/keys with: - * - "Allow library access" - * - "Allow read access to all groups" (for group library imports) + * Links the account through the Zotero OAuth flow. * * Registered via overleafModuleImports.referenceLinkingWidgets. */ @@ -31,10 +28,11 @@ export const ZoteroWidget = function ZoteroWidget() { const { isLoading: isCheckingConn, isError: isErrorConnCheck, + error: connCheckError, runAsync: runAsyncConnCheck, data: isConnected, setData: setConnState, - } = useAsync() + } = useAsync() const { isLoading: isUnlinking, @@ -44,6 +42,8 @@ export const ZoteroWidget = function ZoteroWidget() { const [showUnlinkModal, setShowUnlinkModal] = useState(false) + const needsRelink = isForbidden(connCheckError) + const handleConnCheck = useCallback(() => { runAsyncConnCheck(getJSON('/user/zotero/status')) .catch(err => debugConsole.error(err?.data?.message || err?.message || err)) @@ -58,7 +58,7 @@ export const ZoteroWidget = function ZoteroWidget() { .then(() => setConnState(false)) .catch(err => debugConsole.error(err?.data?.message || err?.message || err)) .finally(() => setShowUnlinkModal(false)) - }, [runAsyncUnlink]) + }, [runAsyncUnlink, setConnState]) if (isCheckingConn) { return ( @@ -69,7 +69,7 @@ export const ZoteroWidget = function ZoteroWidget() {
-

GitHub

+

{t('zotero')}

@@ -96,7 +96,7 @@ export const ZoteroWidget = function ZoteroWidget() { {t('zotero_sync_description', { appName })}

- {isErrorConnCheck && ( + {(isErrorConnCheck && !needsRelink) && ( {isUnlinking ? t('unlinking') : t('unlink')} - ) : isErrorConnCheck ? ( + ) : isErrorConnCheck && !needsRelink ? (