www

Unnamed repository; edit this file 'description' to name the repository.
Log | Files | Refs | Submodules | README | LICENSE

commit cfee7ea9d20d14c5ae5dc6008cee27189576a5a2
parent faf3b94b64b805d9b41721129b5ad49b6d816197
Author: Dan Stillman <dstillman@zotero.org>
Date:   Mon, 14 Dec 2015 19:28:56 -0500

Merge pull request #879 from adomasven/feature/transparent-api-keygen

Restores the functionality of 4.0 for sync settings 
Diffstat:
Mchrome/content/zotero/preferences/preferences_sync.js | 221+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mchrome/content/zotero/preferences/preferences_sync.xul | 398++++++++++++++++++++++++++++++++++++++++++-------------------------------------
Mchrome/content/zotero/xpcom/http.js | 22++++++++++++++++++++--
Mchrome/content/zotero/xpcom/sync/syncAPIClient.js | 54+++++++++++++++++++++++++++++++++++++++++++++++++++---
Mchrome/content/zotero/xpcom/sync/syncLocal.js | 33++++++++++++++++++++++++++++++---
Mchrome/content/zotero/xpcom/sync/syncRunner.js | 162+++++++++++++++++++------------------------------------------------------------
Mchrome/locale/en-US/zotero/preferences.dtd | 4+++-
Mchrome/locale/en-US/zotero/zotero.properties | 5+++--
Mchrome/skin/default/zotero/preferences.css | 24++++++++++++++++++++++++
Mtest/content/runtests.html | 1+
Atest/resource/sinon-as-promised.js | 250+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atest/tests/preferences_syncTest.js | 160+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtest/tests/syncRunnerTest.js | 122+++++++++++++++++++++++++++++++++++++++++++------------------------------------
13 files changed, 1075 insertions(+), 381 deletions(-)

diff --git a/chrome/content/zotero/preferences/preferences_sync.js b/chrome/content/zotero/preferences/preferences_sync.js @@ -24,19 +24,232 @@ */ "use strict"; +Components.utils.import("resource://gre/modules/Services.jsm"); Zotero_Preferences.Sync = { - init: function () { + init: Zotero.Promise.coroutine(function* () { this.updateStorageSettings(null, null, true); - - document.getElementById('sync-api-key').value = Zotero.Sync.Data.Local.getAPIKey(); - + + var username = Zotero.Users.getCurrentUsername() || ""; + var apiKey = Zotero.Sync.Data.Local.getAPIKey(); + this.displayFields(apiKey ? username : ""); + if (apiKey) { + try { + var keyInfo = yield Zotero.Sync.Runner.checkAccess( + Zotero.Sync.Runner.getAPIClient({apiKey}), + {timeout: 5000} + ); + this.displayFields(keyInfo.username); + } + catch (e) { + // API key wrong/invalid + if (!(e instanceof Zotero.HTTP.UnexpectedStatusException) && + !(e instanceof Zotero.HTTP.TimeoutException)) { + Zotero.alert( + window, + Zotero.getString('general.error'), + Zotero.getString('sync.error.apiKeyInvalid', Zotero.clientName) + ); + this.unlinkAccount(false); + } + else { + throw e; + } + } + } + + // TEMP: Disabled //var pass = Zotero.Sync.Storage.WebDAV.password; //if (pass) { // document.getElementById('storage-password').value = pass; //} + }), + + displayFields: function (username) { + document.getElementById('sync-unauthorized').hidden = !!username; + document.getElementById('sync-authorized').hidden = !username; + document.getElementById('sync-reset-tab').disabled = !username; + document.getElementById('sync-username').value = username; + document.getElementById('sync-password').value = ''; + document.getElementById('sync-username-textbox').value = Zotero.Prefs.get('sync.server.username'); + + var img = document.getElementById('sync-status-indicator'); + img.removeAttribute('verified'); + img.removeAttribute('animated'); + }, + + + credentialsKeyPress: function (event) { + var username = document.getElementById('sync-username-textbox'); + username.value = username.value.trim(); + var password = document.getElementById('sync-password'); + + var syncAuthButton = document.getElementById('sync-auth-button'); + + syncAuthButton.setAttribute('disabled', 'true'); + + // When using backspace, the value is not updated until after the keypress event + setTimeout(function() { + if (username.value.length && password.value.length) { + syncAuthButton.setAttribute('disabled', 'false'); + } + }); + + if (event.keyCode == 13) { + Zotero_Preferences.Sync.linkAccount(event); + } + }, + + + linkAccount: Zotero.Promise.coroutine(function* (event) { + var username = document.getElementById('sync-username-textbox').value; + var password = document.getElementById('sync-password').value; + + if (!username.length || !password.length) { + this.updateSyncIndicator(); + return; + } + + // Try to acquire API key with current credentials + this.updateSyncIndicator('animated'); + var json = yield Zotero.Sync.Runner.createAPIKeyFromCredentials(username, password); + this.updateSyncIndicator(); + + // Invalid credentials + if (!json) { + Zotero.alert(window, + Zotero.getString('general.error'), + Zotero.getString('sync.error.invalidLogin') + ); + return; + } + + if (!(yield this.checkUser(json.userID, json.username))) { + // createAPIKeyFromCredentials will have created an API key, + // but user decided not to use it, so we remove it here. + Zotero.Sync.Runner.deleteAPIKey(); + return; + } + + this.displayFields(json.username); + }), + + /** + * Updates the auth indicator icon, depending on status + * @param {string} status + */ + updateSyncIndicator: function (status) { + var img = document.getElementById('sync-status-indicator'); + + img.removeAttribute('animated'); + if (status == 'animated') { + img.setAttribute('animated', true); + } }, + + unlinkAccount: Zotero.Promise.coroutine(function* (showAlert=true) { + if (showAlert) { + if (!Services.prompt.confirm( + null, + Zotero.getString('general.warning'), + Zotero.getString('sync.unlinkWarning', Zotero.clientName) + )) { + return; + } + } + + this.displayFields(); + yield Zotero.Sync.Runner.deleteAPIKey(); + }), + + + /** + * Make sure we're syncing with the same account we used last time, and prompt if not. + * If user accepts, change the current user, delete existing groups, and update relation + * URIs to point to the new user's library. + * + * @param {Integer} userID New userID + * @param {Integer} libraryID New libraryID + * @return {Boolean} - True to continue, false to cancel + */ + checkUser: Zotero.Promise.coroutine(function* (userID, username) { + var lastUserID = Zotero.Users.getCurrentUserID(); + var lastUsername = Zotero.Users.getCurrentUsername(); + + if (lastUserID && lastUserID != userID) { + var groups = Zotero.Groups.getAll(); + + var ps = Components.classes["@mozilla.org/embedcomp/prompt-service;1"] + .getService(Components.interfaces.nsIPromptService); + var buttonFlags = (ps.BUTTON_POS_0) * (ps.BUTTON_TITLE_IS_STRING) + + (ps.BUTTON_POS_1) * (ps.BUTTON_TITLE_CANCEL) + + (ps.BUTTON_POS_2) * (ps.BUTTON_TITLE_IS_STRING) + + ps.BUTTON_POS_1_DEFAULT + + ps.BUTTON_DELAY_ENABLE; + + var msg = Zotero.getString('sync.lastSyncWithDifferentAccount', [lastUsername, username]); + var syncButtonText = Zotero.getString('sync.sync'); + + msg += " " + Zotero.getString('sync.localDataWillBeCombined', username); + // If there are local groups belonging to the previous user, + // we need to remove them + if (groups.length) { + msg += " " + Zotero.getString('sync.localGroupsWillBeRemoved1'); + var syncButtonText = Zotero.getString('sync.removeGroupsAndSync'); + } + msg += "\n\n" + Zotero.getString('sync.avoidCombiningData', lastUsername); + + var index = ps.confirmEx( + null, + Zotero.getString('general.warning'), + msg, + buttonFlags, + syncButtonText, + null, + Zotero.getString('sync.openSyncPreferences'), + null, {} + ); + + if (index > 0) { + if (index == 2) { + var wm = Components.classes["@mozilla.org/appshell/window-mediator;1"] + .getService(Components.interfaces.nsIWindowMediator); + var lastWin = wm.getMostRecentWindow("navigator:browser"); + lastWin.ZoteroPane.openPreferences('zotero-prefpane-sync'); + } + return false; + } + } + + yield Zotero.DB.executeTransaction(function* () { + if (lastUserID != userID) { + if (lastUserID) { + // Delete all local groups if changing users + for (let group of groups) { + yield group.erase(); + } + + // Update relations pointing to the old library to point to this one + yield Zotero.Relations.updateUser(userID); + } + // Replace local user key with libraryID, in case duplicates were + // merged before the first sync + else { + yield Zotero.Relations.updateUser(userID); + } + + yield Zotero.Users.setCurrentUserID(userID); + } + + if (lastUsername != username) { + yield Zotero.Users.setCurrentUsername(username); + } + }) + + return true; + }), + updateStorageSettings: function (enabled, protocol, skipWarnings) { if (enabled === null) { diff --git a/chrome/content/zotero/preferences/preferences_sync.xul b/chrome/content/zotero/preferences/preferences_sync.xul @@ -47,216 +47,240 @@ <tabbox> <tabs> <tab label="&zotero.preferences.settings;"/> - <tab label="&zotero.preferences.sync.reset;"/> + <tab id="sync-reset-tab" label="&zotero.preferences.sync.reset;" disabled="true"/> </tabs> <tabpanels> <tabpanel orient="vertical"> - <groupbox> - <caption label="&zotero.preferences.sync.syncServer;"/> - - <hbox> - <grid> + <vbox id="sync-unauthorized"> + <groupbox> + <caption label="&zotero.preferences.sync.syncServer;"/> + + <hbox> + <grid> + <columns> + <column/> + <column/> + </columns> + + <rows> + <row> + <label value="&zotero.preferences.sync.username;"/> + <textbox id="sync-username-textbox" + preference="pref-sync-username" + onkeypress="Zotero_Preferences.Sync.credentialsKeyPress(event);"/> + </row> + <row> + <label value="&zotero.preferences.sync.password;"/> + <textbox id="sync-password" type="password" + onkeypress="Zotero_Preferences.Sync.credentialsKeyPress(event);"/> + </row> + <vbox align="center"> + <hbox align="baseline"> + <button id="sync-auth-button" + label="&zotero.preferences.sync.setUpSync;" + oncommand="Zotero_Preferences.Sync.linkAccount(event)" + disabled="true"/> + <label id="sync-status-indicator"/> + </hbox> + </vbox> + </rows> + </grid> + <vbox style="width:2em"/> + <vbox> + <label class="zotero-text-link" value="&zotero.preferences.sync.createAccount;" href="http://zotero.org/user/register"/> + <separator class="thin"/> + <label class="zotero-text-link" value="&zotero.preferences.sync.lostPassword;" href="http://zotero.org/user/lostpassword"/> + <separator class="thin"/> + <label class="zotero-text-link" value="&zotero.preferences.sync.about;" href="http://www.zotero.org/support/sync"/> + </vbox> + </hbox> + </groupbox> + </vbox> + + <vbox id="sync-authorized" hidden="true"> + <groupbox> + <caption label="&zotero.preferences.sync.syncServer;"/> + + <hbox> + <grid> + <columns> + <column/> + <column/> + </columns> + + <rows> + <row> + <label value="&zotero.preferences.sync.username;"/> + <label id="sync-username" value="Username"/> + </row> + <row> + <box/> + <button label="&zotero.preferences.sync.unlinkAccount;" + oncommand="Zotero_Preferences.Sync.unlinkAccount()"/> + </row> + <!-- + <row> + <box/> + <button label="Access Control" oncommand="Zotero.alert('Not implemented');"/> + </row> + --> + <row> + <box/> + <checkbox label="&zotero.preferences.sync.syncAutomatically;" + disabled="true"/> + </row> + <row> + <box/> + <checkbox label="&zotero.preferences.sync.syncFullTextContent;" + tooltiptext="&zotero.preferences.sync.syncFullTextContent.desc;" + disabled="true"/> + </row> + </rows> + </grid> + <vbox> + <label class="zotero-text-link" value="&zotero.preferences.sync.about;" href="http://www.zotero.org/support/sync"/> + </vbox> + </hbox> + + </groupbox> + + <groupbox id="storage-settings"> + <caption label="&zotero.preferences.sync.fileSyncing;"/> + + <!-- My Library --> + <hbox> + <checkbox label="&zotero.preferences.sync.fileSyncing.myLibrary;" + preference="pref-storage-enabled" + oncommand="Zotero_Preferences.Sync.updateStorageSettings(this.checked, null)"/> + <menulist id="storage-protocol" class="storage-personal" + style="margin-left: .5em" + preference="pref-storage-protocol" + oncommand="Zotero_Preferences.Sync.updateStorageSettings(null, this.value)"> + <menupopup> + <menuitem label="Zotero" value="zotero"/> + <menuitem label="WebDAV" value="webdav" disabled="true"/><!-- TEMP --> + </menupopup> + </menulist> + </hbox> + + <stack id="storage-webdav-settings" style="margin-top: .5em; margin-bottom: .8em; border: 1px gray solid; border-radius: 3px"> + <!-- Background shading --> + <box style="background: black; opacity:.03"/> + + <grid style="padding: .7em .4em .7em 0"> <columns> <column/> - <column/> + <column flex="1"/> </columns> - + <rows> - <!-- - <row> - <label value="&zotero.preferences.sync.username;"/> - <textbox preference="pref-sync-username" - onchange="this.value = this.value.trim(); Zotero.Prefs.set('sync.server.username', this.value); var pass = document.getElementById('sync-password'); if (pass.value) { Zotero.Sync.Server.password = pass.value; }"/> - </row> <row> - <label value="&zotero.preferences.sync.password;"/> - <textbox id="sync-password" type="password" - onchange="Zotero.Sync.Server.password = this.value"/> - </row> - --> - <row> - <label value="API Key (temp)"/> - <textbox id="sync-api-key" maxlength="24" size="25" - onchange="Zotero.Sync.Data.Local.setAPIKey(this.value)"/> + <label value="&zotero.preferences.sync.fileSyncing.url;"/> + <hbox> + <menulist id="storage-url-prefix" + preference="pref-storage-scheme" + onsynctopreference="Zotero_Preferences.Sync.unverifyStorageServer()" + style="padding: 0; width: 7em"> + <menupopup> + <menuitem label="https" value="https" style="padding: 0"/> + <menuitem label="http" value="http" style="padding: 0"/> + </menupopup> + </menulist> + <label value="://"/> + <textbox id="storage-url" flex="1" + preference="pref-storage-url" + onkeypress="if (Zotero.isMac &amp;&amp; event.keyCode == 13) { + this.blur(); + setTimeout(Zotero_Preferences.Sync.verifyStorageServer, 1); + }" + onchange="Zotero_Preferences.Sync.unverifyStorageServer(); + this.value = this.value.replace(/(^https?:\/\/|\/zotero\/?$|\/$)/g, ''); + Zotero.Prefs.set('sync.storage.url', this.value)"/> + <label value="/zotero/"/> + </hbox> </row> <row> - <box/> - <!--<checkbox label="&zotero.preferences.sync.syncAutomatically;" preference="pref-sync-autosync"/>--> - <checkbox label="&zotero.preferences.sync.syncAutomatically;" - disabled="true"/> + <label value="&zotero.preferences.sync.username;"/> + <hbox> + <textbox id="storage-username" + preference="pref-storage-username" + onkeypress="if (Zotero.isMac &amp;&amp; event.keyCode == 13) { + this.blur(); + setTimeout(Zotero_Preferences.Sync.verifyStorageServer, 1); }" + onchange="Zotero_Preferences.Sync.unverifyStorageServer(); + Zotero.Prefs.set('sync.storage.username', this.value); + var pass = document.getElementById('storage-password'); + if (pass.value) { + Zotero.Sync.Storage.WebDAV.password = pass.value; + }"/> + </hbox> </row> <row> - <box/> - <vbox> - <!--<checkbox label="&zotero.preferences.sync.syncFullTextContent;" - preference="pref-sync-fulltext-enabled" - tooltiptext="&zotero.preferences.sync.syncFullTextContent.desc;"/>--> - <checkbox label="&zotero.preferences.sync.syncFullTextContent;" - tooltiptext="&zotero.preferences.sync.syncFullTextContent.desc;" - disabled="true"/> - </vbox> + <label value="&zotero.preferences.sync.password;"/> + <hbox> + <textbox id="storage-password" flex="0" type="password" + onkeypress="if (Zotero.isMac &amp;&amp; event.keyCode == 13) { + this.blur(); + setTimeout(Zotero_Preferences.Sync.verifyStorageServer, 1); + }" + onchange="Zotero_Preferences.Sync.unverifyStorageServer(); + Zotero.Sync.Storage.WebDAV.password = this.value;"/> + </hbox> </row> - <!-- <row> <box/> <hbox> - <button label="Verify login" - oncommand="alert('Unimplemented')"/> + <button id="storage-verify" label="Verify Server" + oncommand="Zotero_Preferences.Sync.verifyStorageServer()"/> + <button id="storage-abort" label="Stop" hidden="true"/> + <progressmeter id="storage-progress" hidden="true" + mode="undetermined"/> </hbox> </row> - --> </rows> </grid> - - <hbox style="width:2em"/> - + + </stack> + + <hbox class="storage-settings-download-options" align="center"> + <label value="&zotero.preferences.sync.fileSyncing.download;"/> + <menulist class="storage-personal" preference="pref-storage-downloadMode-personal" style="margin-left: 0"> + <menupopup> + <menuitem label="&zotero.preferences.sync.fileSyncing.download.onDemand;" value="on-demand"/> + <menuitem label="&zotero.preferences.sync.fileSyncing.download.atSyncTime;" value="on-sync"/> + </menupopup> + </menulist> + </hbox> + + <separator id="storage-separator" class="thin"/> + + <!-- Group Libraries --> + <checkbox label="&zotero.preferences.sync.fileSyncing.groups;" + preference="pref-group-storage-enabled" + oncommand="Zotero_Preferences.Sync.updateStorageSettingsGroups(this.checked)"/> + + <hbox class="storage-settings-download-options" align="center"> + <label value="&zotero.preferences.sync.fileSyncing.download;"/> + <menulist class="storage-groups" preference="pref-storage-downloadMode-groups" style="margin-left: 0"> + <menupopup> + <menuitem label="&zotero.preferences.sync.fileSyncing.download.onDemand;" value="on-demand"/> + <menuitem label="&zotero.preferences.sync.fileSyncing.download.atSyncTime;" value="on-sync"/> + </menupopup> + </menulist> + </hbox> + + <separator class="thin"/> + <vbox> - <label class="zotero-text-link" value="&zotero.preferences.sync.about;" href="http://www.zotero.org/support/sync"/> - <separator class="thin"/> - <label class="zotero-text-link" value="&zotero.preferences.sync.createAccount;" href="http://zotero.org/user/register"/> - <separator class="thin"/> - <label class="zotero-text-link" value="&zotero.preferences.sync.lostPassword;" href="http://zotero.org/user/lostpassword"/> + <hbox id="storage-terms" style="margin-top: .4em; display: block" align="center"> + <label>&zotero.preferences.sync.fileSyncing.tos1;</label> + <label class="zotero-text-link" href="https://www.zotero.org/support/terms/terms_of_service" value="&zotero.preferences.sync.fileSyncing.tos2;"/> + <label>&zotero.preferences.period;</label> + </hbox> </vbox> - </hbox> - </groupbox> - - - <groupbox id="storage-settings"> - <caption label="&zotero.preferences.sync.fileSyncing;"/> - - <!-- My Library --> - <hbox> - <checkbox label="&zotero.preferences.sync.fileSyncing.myLibrary;" - preference="pref-storage-enabled" - oncommand="Zotero_Preferences.Sync.updateStorageSettings(this.checked, null)"/> - <menulist id="storage-protocol" class="storage-personal" - style="margin-left: .5em" - preference="pref-storage-protocol" - oncommand="Zotero_Preferences.Sync.updateStorageSettings(null, this.value)"> - <menupopup> - <menuitem label="Zotero" value="zotero"/> - <menuitem label="WebDAV" value="webdav" disabled="true"/><!-- TEMP --> - </menupopup> - </menulist> - </hbox> - - <stack id="storage-webdav-settings" style="margin-top: .5em; margin-bottom: .8em; border: 1px gray solid; border-radius: 3px"> - <!-- Background shading --> - <box style="background: black; opacity:.03"/> - - <grid style="padding: .7em .4em .7em 0"> - <columns> - <column/> - <column flex="1"/> - </columns> - - <rows> - <row> - <label value="&zotero.preferences.sync.fileSyncing.url;"/> - <hbox> - <menulist id="storage-url-prefix" - preference="pref-storage-scheme" - onsynctopreference="Zotero_Preferences.Sync.unverifyStorageServer()" - style="padding: 0; width: 7em"> - <menupopup> - <menuitem label="https" value="https" style="padding: 0"/> - <menuitem label="http" value="http" style="padding: 0"/> - </menupopup> - </menulist> - <label value="://"/> - <textbox id="storage-url" flex="1" - preference="pref-storage-url" - onkeypress="if (Zotero.isMac &amp;&amp; event.keyCode == 13) { - this.blur(); - setTimeout(Zotero_Preferences.Sync.verifyStorageServer, 1); - }" - onchange="Zotero_Preferences.Sync.unverifyStorageServer(); - this.value = this.value.replace(/(^https?:\/\/|\/zotero\/?$|\/$)/g, ''); - Zotero.Prefs.set('sync.storage.url', this.value)"/> - <label value="/zotero/"/> - </hbox> - </row> - <row> - <label value="&zotero.preferences.sync.username;"/> - <hbox> - <textbox id="storage-username" - preference="pref-storage-username" - onkeypress="if (Zotero.isMac &amp;&amp; event.keyCode == 13) { - this.blur(); - setTimeout(Zotero_Preferences.Sync.verifyStorageServer, 1); }" - onchange="Zotero_Preferences.Sync.unverifyStorageServer(); - Zotero.Prefs.set('sync.storage.username', this.value); - var pass = document.getElementById('storage-password'); - if (pass.value) { - Zotero.Sync.Storage.WebDAV.password = pass.value; - }"/> - </hbox> - </row> - <row> - <label value="&zotero.preferences.sync.password;"/> - <hbox> - <textbox id="storage-password" flex="0" type="password" - onkeypress="if (Zotero.isMac &amp;&amp; event.keyCode == 13) { - this.blur(); - setTimeout(Zotero_Preferences.Sync.verifyStorageServer, 1); - }" - onchange="Zotero_Preferences.Sync.unverifyStorageServer(); - Zotero.Sync.Storage.WebDAV.password = this.value;"/> - </hbox> - </row> - <row> - <box/> - <hbox> - <button id="storage-verify" label="Verify Server" - oncommand="Zotero_Preferences.Sync.verifyStorageServer()"/> - <button id="storage-abort" label="Stop" hidden="true"/> - <progressmeter id="storage-progress" hidden="true" - mode="undetermined"/> - </hbox> - </row> - </rows> - </grid> - - </stack> - - <hbox class="storage-settings-download-options" align="center"> - <label value="&zotero.preferences.sync.fileSyncing.download;"/> - <menulist class="storage-personal" preference="pref-storage-downloadMode-personal" style="margin-left: 0"> - <menupopup> - <menuitem label="&zotero.preferences.sync.fileSyncing.download.onDemand;" value="on-demand"/> - <menuitem label="&zotero.preferences.sync.fileSyncing.download.atSyncTime;" value="on-sync"/> - </menupopup> - </menulist> - </hbox> - - <separator id="storage-separator" class="thin"/> - - <!-- Group Libraries --> - <checkbox label="&zotero.preferences.sync.fileSyncing.groups;" - preference="pref-group-storage-enabled" - oncommand="Zotero_Preferences.Sync.updateStorageSettingsGroups(this.checked)"/> - - <hbox class="storage-settings-download-options" align="center"> - <label value="&zotero.preferences.sync.fileSyncing.download;"/> - <menulist class="storage-groups" preference="pref-storage-downloadMode-groups" style="margin-left: 0"> - <menupopup> - <menuitem label="&zotero.preferences.sync.fileSyncing.download.onDemand;" value="on-demand"/> - <menuitem label="&zotero.preferences.sync.fileSyncing.download.atSyncTime;" value="on-sync"/> - </menupopup> - </menulist> - </hbox> - - <separator class="thin"/> - - <vbox> - <hbox id="storage-terms" style="margin-top: .4em; display: block" align="center"> - <label>&zotero.preferences.sync.fileSyncing.tos1;</label> - <label class="zotero-text-link" href="https://www.zotero.org/support/terms/terms_of_service" value="&zotero.preferences.sync.fileSyncing.tos2;"/> - <label>&zotero.preferences.period;</label> - </hbox> - </vbox> - </groupbox> + </groupbox> + </vbox> </tabpanel> <tabpanel id="zotero-reset" orient="vertical"> diff --git a/chrome/content/zotero/xpcom/http.js b/chrome/content/zotero/xpcom/http.js @@ -55,7 +55,15 @@ Zotero.HTTP = new function() { this.BrowserOfflineException.prototype.toString = function() { return this.message; }; - + + this.TimeoutException = function(ms) { + this.message = "XMLHttpRequest has timed out after " + ms + "ms"; + }; + this.TimeoutException.prototype = Object.create(Error.prototype); + this.TimeoutException.prototype.toString = function() { + return this.message; + }; + this.promise = function () { Zotero.debug("Zotero.HTTP.promise() is deprecated -- use Zotero.HTTP.request()", 2); return this.request.apply(this, arguments); @@ -74,6 +82,7 @@ Zotero.HTTP = new function() { * <li>dontCache - If set, specifies that the request should not be fulfilled from the cache</li> * <li>foreground - Make a foreground request, showing certificate/authentication dialogs if necessary</li> * <li>headers - HTTP headers to include in the request</li> + * <li>timeout - Request timeout specified in milliseconds * <li>requestObserver - Callback to receive XMLHttpRequest after open()</li> * <li>responseType - The type of the response. See XHR 2 documentation for legal values</li> * <li>responseCharset - The charset the response should be interpreted as</li> @@ -191,7 +200,16 @@ Zotero.HTTP = new function() { for (var header in headers) { xmlhttp.setRequestHeader(header, headers[header]); } - + + // Set timeout + if (options.timeout) { + xmlhttp.timeout = options.timeout; + } + + xmlhttp.ontimeout = function() { + deferred.reject(new Zotero.HTTP.TimeoutException(options.timeout)); + }; + xmlhttp.onloadend = function() { var status = xmlhttp.status; diff --git a/chrome/content/zotero/xpcom/sync/syncAPIClient.js b/chrome/content/zotero/xpcom/sync/syncAPIClient.js @@ -30,7 +30,6 @@ if (!Zotero.Sync) { Zotero.Sync.APIClient = function (options) { if (!options.baseURL) throw new Error("baseURL not set"); if (!options.apiVersion) throw new Error("apiVersion not set"); - if (!options.apiKey) throw new Error("apiKey not set"); if (!options.caller) throw new Error("caller not set"); this.baseURL = options.baseURL; @@ -45,9 +44,9 @@ Zotero.Sync.APIClient.prototype = { MAX_OBJECTS_PER_REQUEST: 100, - getKeyInfo: Zotero.Promise.coroutine(function* () { + getKeyInfo: Zotero.Promise.coroutine(function* (options={}) { var uri = this.baseURL + "keys/" + this.apiKey; - var xmlhttp = yield this.makeRequest("GET", uri, { successCodes: [200, 404] }); + var xmlhttp = yield this.makeRequest("GET", uri, Object.assign(options, { successCodes: [200, 404] })); if (xmlhttp.status == 404) { return false; } @@ -430,6 +429,52 @@ Zotero.Sync.APIClient.prototype = { }), + createAPIKeyFromCredentials: Zotero.Promise.coroutine(function* (username, password) { + var body = JSON.stringify({ + username, + password, + name: "Automatic Zotero Client Key", + access: { + user: { + library: true, + notes: true, + write: true, + files: true + }, + groups: { + all: { + library: true, + write: true + } + } + } + }); + var headers = { + "Content-Type": "application/json" + }; + var uri = this.baseURL + "keys"; + var response = yield this.makeRequest("POST", uri, { + body, headers, successCodes: [201, 403], noAPIKey: true + }); + if (response.status == 403) { + return false; + } + + var json = this._parseJSON(response.responseText); + if (!json.key) { + throw new Error('json.key not present in POST /keys response') + } + + return json; + }), + + + // Deletes current API key + deleteAPIKey: Zotero.Promise.coroutine(function* () { + yield this.makeRequest("DELETE", this.baseURL + "keys/" + this.apiKey); + }), + + buildRequestURI: function (params) { var uri = this.baseURL; @@ -508,6 +553,9 @@ Zotero.Sync.APIClient.prototype = { makeRequest: Zotero.Promise.coroutine(function* (method, uri, options = {}) { + if (!this.apiKey && !options.noAPIKey) { + throw new Error('API key not set'); + } options.headers = this.getHeaders(options.headers); options.dontCache = true; options.foreground = !options.background; diff --git a/chrome/content/zotero/xpcom/sync/syncLocal.js b/chrome/content/zotero/xpcom/sync/syncLocal.js @@ -55,9 +55,11 @@ Zotero.Sync.Data.Local = { var oldLoginInfo = this._getAPIKeyLoginInfo(); // Clear old login - if (oldLoginInfo && (!apiKey || apiKey === "")) { - Zotero.debug("Clearing old API key"); - loginManager.removeLogin(oldLoginInfo); + if ((!apiKey || apiKey === "")) { + if (oldLoginInfo) { + Zotero.debug("Clearing old API key"); + loginManager.removeLogin(oldLoginInfo); + } return; } @@ -154,6 +156,31 @@ Zotero.Sync.Data.Local = { }, + removeLegacyLogins: function () { + var loginManagerHost = 'chrome://zotero'; + var loginManagerRealm = 'Zotero Sync Server'; + + Zotero.debug('Removing legacy Zotero sync credentials (api key acquired)'); + + var loginManager = Components.classes["@mozilla.org/login-manager;1"] + .getService(Components.interfaces.nsILoginManager); + try { + var logins = loginManager.findLogins({}, loginManagerHost, null, loginManagerRealm); + } + catch (e) { + Zotero.logError(e); + return ''; + } + + // Remove all legacy users + for (let login of logins) { + loginManager.removeLogin(login); + } + // Remove the legacy pref + Zotero.Pref.clear('sync.server.username'); + }, + + getLastSyncTime: function () { if (_lastSyncTime === null) { throw new Error("Last sync time not yet loaded"); diff --git a/chrome/content/zotero/xpcom/sync/syncRunner.js b/chrome/content/zotero/xpcom/sync/syncRunner.js @@ -68,12 +68,9 @@ Zotero.Sync.Runner_Module = function (options = {}) { var _currentLastSyncLabel; var _errors = []; - + this.getAPIClient = function (options = {}) { - if (!options.apiKey) { - throw new Error("apiKey not provided"); - } - + return new Zotero.Sync.APIClient({ baseURL: this.baseURL, apiVersion: this.apiVersion, @@ -218,8 +215,8 @@ Zotero.Sync.Runner_Module = function (options = {}) { /** * Check key for current user info and return access info */ - this.checkAccess = Zotero.Promise.coroutine(function* (client, options) { - var json = yield client.getKeyInfo(); + this.checkAccess = Zotero.Promise.coroutine(function* (client, options={}) { + var json = yield client.getKeyInfo(options); Zotero.debug(json); if (!json) { // TODO: Nicer error message @@ -231,11 +228,6 @@ Zotero.Sync.Runner_Module = function (options = {}) { if (!json.username) throw new Error("username not found in key response"); if (!json.access) throw new Error("'access' not found in key response"); - // Make sure user hasn't changed, and prompt to update database if so - if (!(yield this.checkUser(json.userID, json.username))) { - return false; - } - return json; }); @@ -426,108 +418,6 @@ Zotero.Sync.Runner_Module = function (options = {}) { /** - * Make sure we're syncing with the same account we used last time, and prompt if not. - * If user accepts, change the current user, delete existing groups, and update relation - * URIs to point to the new user's library. - * - * @param {Integer} userID New userID - * @param {Integer} libraryID New libraryID - * @return {Boolean} - True to continue, false to cancel - */ - this.checkUser = Zotero.Promise.coroutine(function* (userID, username) { - var lastUserID = Zotero.Users.getCurrentUserID(); - var lastUsername = Zotero.Users.getCurrentUsername(); - - // TEMP: Remove? No way to determine this quickly currently. - var noServerData = false; - - if (lastUserID && lastUserID != userID) { - var groups = Zotero.Groups.getAll(); - - var ps = Components.classes["@mozilla.org/embedcomp/prompt-service;1"] - .getService(Components.interfaces.nsIPromptService); - var buttonFlags = (ps.BUTTON_POS_0) * (ps.BUTTON_TITLE_IS_STRING) - + (ps.BUTTON_POS_1) * (ps.BUTTON_TITLE_CANCEL) - + (ps.BUTTON_POS_2) * (ps.BUTTON_TITLE_IS_STRING) - + ps.BUTTON_POS_1_DEFAULT - + ps.BUTTON_DELAY_ENABLE; - - var msg = Zotero.getString('sync.lastSyncWithDifferentAccount', [lastUsername, username]); - - if (!noServerData) { - msg += " " + Zotero.getString('sync.localDataWillBeCombined', username); - // If there are local groups belonging to the previous user, - // we need to remove them - if (groups.length) { - msg += " " + Zotero.getString('sync.localGroupsWillBeRemoved1'); - } - msg += "\n\n" + Zotero.getString('sync.avoidCombiningData', lastUsername); - var syncButtonText = Zotero.getString('sync.sync'); - } - else if (groups.length) { - msg += " " + Zotero.getString('sync.localGroupsWillBeRemoved2', [username, lastUsername]); - var syncButtonText = Zotero.getString('sync.removeGroupsAndSync'); - } - // If there are no local groups and the server is empty, - // don't bother prompting - else { - var noPrompt = true; - } - - if (!noPrompt) { - var index = ps.confirmEx( - null, - Zotero.getString('general.warning'), - msg, - buttonFlags, - syncButtonText, - null, - Zotero.getString('sync.openSyncPreferences'), - null, {} - ); - - if (index > 0) { - if (index == 2) { - var wm = Components.classes["@mozilla.org/appshell/window-mediator;1"] - .getService(Components.interfaces.nsIWindowMediator); - var lastWin = wm.getMostRecentWindow("navigator:browser"); - lastWin.ZoteroPane.openPreferences('zotero-prefpane-sync'); - } - return false; - } - } - } - - yield Zotero.DB.executeTransaction(function* () { - if (lastUserID != userID) { - if (lastUserID) { - // Delete all local groups if changing users - for (let group of groups) { - yield group.erase(); - } - - // Update relations pointing to the old library to point to this one - yield Zotero.Relations.updateUser(userID); - } - // Replace local user key with libraryID, in case duplicates were - // merged before the first sync - else { - yield Zotero.Relations.updateUser(userID); - } - - yield Zotero.Users.setCurrentUserID(userID); - } - - if (lastUsername != username) { - yield Zotero.Users.setCurrentUsername(username); - } - }) - - return true; - }); - - - /** * Run sync engine for passed libraries * * @param {Integer[]} libraries @@ -1191,7 +1081,33 @@ Zotero.Sync.Runner_Module = function (options = {}) { _updateSyncStatusLabel(); } } - + + + this.createAPIKeyFromCredentials = Zotero.Promise.coroutine(function* (username, password) { + var client = this.getAPIClient(); + var json = yield client.createAPIKeyFromCredentials(username, password); + if (!json) { + return false; + } + + // Sanity check + if (!json.userID) throw new Error("userID not found in key response"); + if (!json.username) throw new Error("username not found in key response"); + if (!json.access) throw new Error("'access' not found in key response"); + + Zotero.Sync.Data.Local.setAPIKey(json.key); + + return json; + }) + + + this.deleteAPIKey = Zotero.Promise.coroutine(function* (){ + var apiKey = Zotero.Sync.Data.Local.getAPIKey(); + var client = this.getAPIClient({apiKey}); + Zotero.Sync.Data.Local.setAPIKey(); + yield client.deleteAPIKey(); + }) + function _updateSyncStatusLabel() { if (_lastSyncStatus) { @@ -1244,20 +1160,20 @@ Zotero.Sync.Runner_Module = function (options = {}) { var _getAPIKeyFromLogin = Zotero.Promise.coroutine(function* () { - var apiKey = ""; + var apiKey; let username = Zotero.Prefs.get('sync.server.username'); if (username) { + // Check for legacy password if no password set in current session + // and no API keys stored yet let password = Zotero.Sync.Data.Local.getLegacyPassword(username); if (!password) { return ""; } - throw new Error("Unimplemented"); - // Get API key from server - - // Store API key - - // Remove old logins and username pref + + apiKey = yield Zotero.Sync.Runner.createAPIKeyFromCredentials(username, password); + Zotero.Sync.Data.Local.removeLegacyLogins(); + return apiKey; } - return apiKey; + return ""; }) } diff --git a/chrome/locale/en-US/zotero/preferences.dtd b/chrome/locale/en-US/zotero/preferences.dtd @@ -49,7 +49,9 @@ <!ENTITY zotero.preferences.prefpane.sync "Sync"> <!ENTITY zotero.preferences.sync.username "Username:"> <!ENTITY zotero.preferences.sync.password "Password:"> -<!ENTITY zotero.preferences.sync.syncServer "Zotero Sync Server"> +<!ENTITY zotero.preferences.sync.syncServer "Zotero Data Sync"> +<!ENTITY zotero.preferences.sync.setUpSync "Set Up Syncing"> +<!ENTITY zotero.preferences.sync.unlinkAccount "Unlink Account..."> <!ENTITY zotero.preferences.sync.createAccount "Create Account"> <!ENTITY zotero.preferences.sync.lostPassword "Lost Password?"> <!ENTITY zotero.preferences.sync.syncAutomatically "Sync automatically"> diff --git a/chrome/locale/en-US/zotero/zotero.properties b/chrome/locale/en-US/zotero/zotero.properties @@ -821,12 +821,13 @@ sync.error.sslConnectionError = SSL connection error sync.error.checkConnection = Error connecting to server. Check your Internet connection. sync.error.emptyResponseServer = Empty response from server. sync.error.invalidCharsFilename = The filename '%S' contains invalid characters.\n\nRename the file and try again. If you rename the file via the OS, you will need to relink it in Zotero. +sync.error.apiKeyInvalid = %S could not authenticate your account. Please re-enter your account details. sync.lastSyncWithDifferentAccount = This Zotero database was last synced with a different zotero.org account ('%1$S') from the current one ('%2$S'). sync.localDataWillBeCombined = If you continue, local Zotero data will be combined with data from the '%S' account stored on the server. sync.localGroupsWillBeRemoved1 = Local groups, including any with changed items, will also be removed from this computer. -sync.avoidCombiningData = To avoid combining or losing data, revert to the '%S' account or use the Reset options in the Sync pane of the Zotero preferences. -sync.localGroupsWillBeRemoved2 = If you continue, local groups, including any with changed items, will be removed and replaced with groups linked to the '%1$S' account.\n\nTo avoid losing local changes to groups, be sure you have synced with the '%2$S' account before syncing with the '%1$S' account. +sync.avoidCombiningData = To avoid combining data, revert to the '%S' account or use the Reset options in the Sync pane of the Zotero preferences. +sync.unlinkWarning = Are you sure you want to unlink this account?\n\n%S will no longer sync your data, but your data will remain locally. sync.conflict.autoChange.alert = One or more locally deleted Zotero %S have been modified remotely since the last sync. sync.conflict.autoChange.log = A Zotero %S has changed both locally and remotely since the last sync: diff --git a/chrome/skin/default/zotero/preferences.css b/chrome/skin/default/zotero/preferences.css @@ -118,6 +118,30 @@ grid row hbox:first-child margin-right: 10px; } +#zotero-prefpane-sync #sync-status-indicator +{ + width: 1.5em; + height: 1.7em; + margin-top: 0.4em; + background-repeat: no-repeat; + background-position: center; +} + +#zotero-prefpane-sync #sync-status-indicator[verified=true] +{ + background-image: url("chrome://zotero/skin/tick.png") +} + +#zotero-prefpane-sync #sync-status-indicator[verified=false] +{ + background-image: url("chrome://zotero/skin/cross.png") +} + +#zotero-prefpane-sync #sync-status-indicator[animated] +{ + background-image: url("chrome://zotero/skin/arrow_rotate_animated.png") +} + .storage-settings-download-options { margin-left: 40px; diff --git a/test/content/runtests.html b/test/content/runtests.html @@ -10,6 +10,7 @@ <script src="resource://zotero-unit/chai-as-promised/lib/chai-as-promised.js"></script> <script src="resource://zotero-unit/mocha/mocha.js"></script> <script src="resource://zotero-unit/sinon.js"></script> + <script src="resource://zotero-unit/sinon-as-promised.js"></script> <script src="support.js" type="application/javascript;version=1.8"></script> <script src="runtests.js" type="application/javascript;version=1.8"></script> </body> diff --git a/test/resource/sinon-as-promised.js b/test/resource/sinon-as-promised.js @@ -0,0 +1,250 @@ +(function(f){if(typeof exports==="object"&&typeof module!=="undefined"){module.exports=f()}else if(typeof define==="function"&&define.amd){define([],f)}else{var g;if(typeof window!=="undefined"){g=window}else if(typeof global!=="undefined"){g=global}else if(typeof self!=="undefined"){g=self}else{g=this}g.sinonAsPromised = f()}})(function(){var define,module,exports;return (function e(t,n,r){function s(o,u){if(!n[o]){if(!t[o]){var a=typeof require=="function"&&require;if(!u&&a)return a(o,!0);if(i)return i(o,!0);var f=new Error("Cannot find module '"+o+"'");throw f.code="MODULE_NOT_FOUND",f}var l=n[o]={exports:{}};t[o][0].call(l.exports,function(e){var n=t[o][1][e];return s(n?n:e)},l,l.exports,e,t,n,r)}return n[o].exports}var i=typeof require=="function"&&require;for(var o=0;o<r.length;o++)s(r[o]);return s})({1:[function(require,module,exports){ +'use strict' + +var Promise = window.Zotero.Promise +var sinon = (window.sinon) + +function methods (Promise) { + return ['catch', 'finally'].concat(Object.keys(Promise.prototype)).filter(a => a != 'then'); +} +function createThenable (Promise, resolver) { + return methods(Promise).reduce(createMethod, {then: then}) + function createMethod (thenable, name) { + thenable[name] = method(name) + return thenable + } + function method (name) { + return function () { + var promise = this.then() + return promise[name].apply(promise, arguments) + } + } + function then (/*onFulfill, onReject*/) { + var promise = new Promise(resolver) + return promise.then.apply(promise, arguments) + } +} + +function resolves (value) { + return this.returns(createThenable(Promise, function (resolve) { + resolve(value) + })) +} + +sinon.stub.resolves = resolves +sinon.behavior.resolves = resolves + +function rejects (err) { + if (typeof err === 'string') { + err = new Error(err) + } + return this.returns(createThenable(Promise, function (resolve, reject) { + reject(err) + })) +} + +sinon.stub.rejects = rejects +sinon.behavior.rejects = rejects + +module.exports = function (_Promise_) { + if (typeof _Promise_ !== 'function') { + throw new Error('A Promise constructor must be provided') + } else { + Promise = _Promise_ + } + return sinon +} + +},{"create-thenable":7,"native-promise-only":8}],2:[function(require,module,exports){ +/*! + * object.omit <https://github.com/jonschlinkert/object.omit> + * + * Copyright (c) 2014-2015 Jon Schlinkert. + * Licensed under the MIT License + */ + +'use strict'; + +var isObject = require('isobject'); +var forOwn = require('for-own'); + +module.exports = function omit(obj, props) { + if (obj == null || !isObject(obj)) { + return {}; + } + + if (props == null) { + return obj; + } + + if (typeof props === 'string') { + props = [].slice.call(arguments, 1); + } + + var o = {}; + + if (!Object.keys(obj).length) { + return o; + } + + forOwn(obj, function (value, key) { + if (props.indexOf(key) === -1) { + o[key] = value; + } + }); + return o; +}; +},{"for-own":3,"isobject":5}],3:[function(require,module,exports){ +/*! + * for-own <https://github.com/jonschlinkert/for-own> + * + * Copyright (c) 2014-2015, Jon Schlinkert. + * Licensed under the MIT License. + */ + +'use strict'; + +var forIn = require('for-in'); +var hasOwn = Object.prototype.hasOwnProperty; + +module.exports = function forOwn(o, fn, thisArg) { + forIn(o, function (val, key) { + if (hasOwn.call(o, key)) { + return fn.call(thisArg, o[key], key, o); + } + }); +}; + +},{"for-in":4}],4:[function(require,module,exports){ +/*! + * for-in <https://github.com/jonschlinkert/for-in> + * + * Copyright (c) 2014-2015, Jon Schlinkert. + * Licensed under the MIT License. + */ + +'use strict'; + +module.exports = function forIn(o, fn, thisArg) { + for (var key in o) { + if (fn.call(thisArg, o[key], key, o) === false) { + break; + } + } +}; +},{}],5:[function(require,module,exports){ +/*! + * isobject <https://github.com/jonschlinkert/isobject> + * + * Copyright (c) 2014 Jon Schlinkert, contributors. + * Licensed under the MIT License + */ + +'use strict'; + +/** + * is the value an object, and not an array? + * + * @param {*} `value` + * @return {Boolean} + */ + +module.exports = function isObject(o) { + return o != null && typeof o === 'object' + && !Array.isArray(o); +}; +},{}],6:[function(require,module,exports){ +'use strict'; + +/** + * Concatenates two arrays, removing duplicates in the process and returns one array with unique values. + * In case the elements in the array don't have a proper built in way to determine their identity, + * a custom identity function must be provided. + * + * As an example, {Object}s all return '[ 'object' ]' when .toString()ed and therefore require a custom + * identity function. + * + * @name exports + * @function unique-concat + * @param arr1 {Array} first batch of elements + * @param arr2 {Array} second batch of elements + * @param identity {Function} (optional) supply an alternative way to get an element's identity + */ +var go = module.exports = function uniqueConcat(arr1, arr2, identity) { + + if (!arr1 || !arr2) throw new Error('Need two arrays to merge'); + if (!Array.isArray(arr1)) throw new Error('First argument is not an array, but a ' + typeof arr1); + if (!Array.isArray(arr2)) throw new Error('Second argument is not an array, but a ' + typeof arr2); + if (identity && typeof identity !== 'function') throw new Error('Third argument should be a function'); + + function hashify(acc, k) { + acc[identity ? identity(k) : k] = k; + return acc; + } + + var arr1Hash = arr1.reduce(hashify, {}); + var mergedHash = arr2.reduce(hashify, arr1Hash); + + return Object.keys(mergedHash).map(function (key) { return mergedHash[key]; }); +}; + +},{}],7:[function(require,module,exports){ +'use strict'; + +Object.defineProperty(exports, '__esModule', { + value: true +}); + +var _extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; }; + +exports['default'] = createThenable; + +function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { 'default': obj }; } + +function _defineProperty(obj, key, value) { return Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } + +var _uniqueConcat = require('unique-concat'); + +var _uniqueConcat2 = _interopRequireDefault(_uniqueConcat); + +var _objectOmit = require('object-omit'); + +var _objectOmit2 = _interopRequireDefault(_objectOmit); + +'use strict'; + +function createThenable(Promise, resolver) { + return methods(Promise).reduce(createMethod, { then: then }); + function createMethod(thenable, name) { + return _extends(thenable, _defineProperty({}, name, method(name))); + } + function method(name) { + return function () { + var _then; + + return (_then = this.then())[name].apply(_then, arguments); + }; + } + function then() { + var _ref; + + return (_ref = new Promise(resolver)).then.apply(_ref, arguments); + } +} + +function methods(Promise) { + return _uniqueConcat2['default'](['catch', 'finally'], Object.keys(_objectOmit2['default'](Promise.prototype, 'then'))); +} +module.exports = exports['default']; +/*onFulfill, onReject*/ +},{"object-omit":2,"unique-concat":6}],8:[function(require,module,exports){ +(function (global){ +/*! Native Promise Only + v0.7.8-a (c) Kyle Simpson + MIT License: http://getify.mit-license.org +*/ +!function(t,n,e){n[t]=n[t]||e(),"undefined"!=typeof module&&module.exports?module.exports=n[t]:"function"==typeof define&&define.amd&&define(function(){return n[t]})}("Promise","undefined"!=typeof global?global:this,function(){"use strict";function t(t,n){l.add(t,n),h||(h=y(l.drain))}function n(t){var n,e=typeof t;return null==t||"object"!=e&&"function"!=e||(n=t.then),"function"==typeof n?n:!1}function e(){for(var t=0;t<this.chain.length;t++)o(this,1===this.state?this.chain[t].success:this.chain[t].failure,this.chain[t]);this.chain.length=0}function o(t,e,o){var r,i;try{e===!1?o.reject(t.msg):(r=e===!0?t.msg:e.call(void 0,t.msg),r===o.promise?o.reject(TypeError("Promise-chain cycle")):(i=n(r))?i.call(r,o.resolve,o.reject):o.resolve(r))}catch(c){o.reject(c)}}function r(o){var c,u,a=this;if(!a.triggered){a.triggered=!0,a.def&&(a=a.def);try{(c=n(o))?(u=new f(a),c.call(o,function(){r.apply(u,arguments)},function(){i.apply(u,arguments)})):(a.msg=o,a.state=1,a.chain.length>0&&t(e,a))}catch(s){i.call(u||new f(a),s)}}}function i(n){var o=this;o.triggered||(o.triggered=!0,o.def&&(o=o.def),o.msg=n,o.state=2,o.chain.length>0&&t(e,o))}function c(t,n,e,o){for(var r=0;r<n.length;r++)!function(r){t.resolve(n[r]).then(function(t){e(r,t)},o)}(r)}function f(t){this.def=t,this.triggered=!1}function u(t){this.promise=t,this.state=0,this.triggered=!1,this.chain=[],this.msg=void 0}function a(n){if("function"!=typeof n)throw TypeError("Not a function");if(0!==this.__NPO__)throw TypeError("Not a promise");this.__NPO__=1;var o=new u(this);this.then=function(n,r){var i={success:"function"==typeof n?n:!0,failure:"function"==typeof r?r:!1};return i.promise=new this.constructor(function(t,n){if("function"!=typeof t||"function"!=typeof n)throw TypeError("Not a function");i.resolve=t,i.reject=n}),o.chain.push(i),0!==o.state&&t(e,o),i.promise},this["catch"]=function(t){return this.then(void 0,t)};try{n.call(void 0,function(t){r.call(o,t)},function(t){i.call(o,t)})}catch(c){i.call(o,c)}}var s,h,l,p=Object.prototype.toString,y="undefined"!=typeof setImmediate?function(t){return setImmediate(t)}:setTimeout;try{Object.defineProperty({},"x",{}),s=function(t,n,e,o){return Object.defineProperty(t,n,{value:e,writable:!0,configurable:o!==!1})}}catch(d){s=function(t,n,e){return t[n]=e,t}}l=function(){function t(t,n){this.fn=t,this.self=n,this.next=void 0}var n,e,o;return{add:function(r,i){o=new t(r,i),e?e.next=o:n=o,e=o,o=void 0},drain:function(){var t=n;for(n=e=h=void 0;t;)t.fn.call(t.self),t=t.next}}}();var g=s({},"constructor",a,!1);return a.prototype=g,s(g,"__NPO__",0,!1),s(a,"resolve",function(t){var n=this;return t&&"object"==typeof t&&1===t.__NPO__?t:new n(function(n,e){if("function"!=typeof n||"function"!=typeof e)throw TypeError("Not a function");n(t)})}),s(a,"reject",function(t){return new this(function(n,e){if("function"!=typeof n||"function"!=typeof e)throw TypeError("Not a function");e(t)})}),s(a,"all",function(t){var n=this;return"[object Array]"!=p.call(t)?n.reject(TypeError("Not an array")):0===t.length?n.resolve([]):new n(function(e,o){if("function"!=typeof e||"function"!=typeof o)throw TypeError("Not a function");var r=t.length,i=Array(r),f=0;c(n,t,function(t,n){i[t]=n,++f===r&&e(i)},o)})}),s(a,"race",function(t){var n=this;return"[object Array]"!=p.call(t)?n.reject(TypeError("Not an array")):new n(function(e,o){if("function"!=typeof e||"function"!=typeof o)throw TypeError("Not a function");c(n,t,function(t,n){e(n)},o)})}),a}); + +}).call(this,typeof global !== "undefined" ? global : typeof self !== "undefined" ? self : typeof window !== "undefined" ? window : {}) +},{}]},{},[1])(1) +}); diff --git a/test/tests/preferences_syncTest.js b/test/tests/preferences_syncTest.js @@ -0,0 +1,160 @@ +describe("Sync Preferences", function () { + var win, doc; + before(function* () { + // Load prefs with sync pane + win = yield loadWindow("chrome://zotero/content/preferences/preferences.xul", { + pane: 'zotero-prefpane-sync', + tabIndex: 0 + }); + doc = win.document; + let defer = Zotero.Promise.defer(); + let pane = doc.getElementById('zotero-prefpane-sync'); + if (!pane.loaded) { + pane.addEventListener('paneload', function () { + defer.resolve(); + }); + yield defer.promise; + } + }); + + after(function() { + win.close(); + }); + + describe("Settings", function () { + describe("Zotero Data Sync", function () { + var getAPIKeyFromCredentialsStub, deleteAPIKey, indicatorElem; + + var setCredentials = Zotero.Promise.coroutine(function* (username, password) { + let usernameElem = doc.getElementById('sync-username-textbox'); + let passwordElem = doc.getElementById('sync-password'); + usernameElem.value = username; + passwordElem.value = password; + + // Triggered by `change` event for usernameElem and passwordElem; + yield win.Zotero_Preferences.Sync.linkAccount(); + }); + + var apiKey = Zotero.Utilities.randomString(24); + + var apiResponse = { + key: apiKey, + username: "Username", + userID: 1, + access: {} + }; + + before(function* () { + getAPIKeyFromCredentialsStub = sinon.stub( + Zotero.Sync.APIClient.prototype, 'createAPIKeyFromCredentials'); + deleteAPIKey = sinon.stub(Zotero.Sync.APIClient.prototype, 'deleteAPIKey').resolves(); + indicatorElem = doc.getElementById('sync-status-indicator') + sinon.stub(Zotero, 'alert'); + }); + + beforeEach(function* (){ + yield win.Zotero_Preferences.Sync.unlinkAccount(false); + deleteAPIKey.reset(); + Zotero.alert.reset(); + }); + + after(function() { + Zotero.HTTP.mock = null; + Zotero.alert.restore(); + getAPIKeyFromCredentialsStub.restore(); + deleteAPIKey.restore(); + win.close(); + }); + + it("should set API key and display full controls with correct credentials", function* () { + getAPIKeyFromCredentialsStub.resolves(apiResponse); + yield setCredentials("Username", "correctPassword"); + + assert.equal(Zotero.Sync.Data.Local.getAPIKey(), apiKey); + assert.equal(doc.getElementById('sync-unauthorized').getAttribute('hidden'), 'true'); + }); + + + it("should display dialog when credentials incorrect", function* () { + getAPIKeyFromCredentialsStub.resolves(false); + yield setCredentials("Username", "incorrectPassword"); + + assert.isTrue(Zotero.alert.called); + assert.equal(Zotero.Sync.Data.Local.getAPIKey(), ""); + assert.equal(doc.getElementById('sync-authorized').getAttribute('hidden'), 'true'); + }); + + + it("should delete API key and display auth form when 'Unlink Account' clicked", function* () { + getAPIKeyFromCredentialsStub.resolves(apiResponse); + yield setCredentials("Username", "correctPassword"); + assert.equal(Zotero.Sync.Data.Local.getAPIKey(), apiKey); + + yield win.Zotero_Preferences.Sync.unlinkAccount(false); + + assert.isTrue(deleteAPIKey.called); + assert.equal(Zotero.Sync.Data.Local.getAPIKey(), ""); + assert.equal(doc.getElementById('sync-authorized').getAttribute('hidden'), 'true'); + }); + + }) + }) + + describe("#checkUser()", function () { + it("should prompt for user update and perform on accept", function* () { + yield Zotero.Users.setCurrentUserID(1); + yield Zotero.Users.setCurrentUsername("A"); + + waitForDialog(function (dialog) { + var text = dialog.document.documentElement.textContent; + var matches = text.match(/'[^']*'/g); + assert.equal(matches.length, 4); + assert.equal(matches[0], "'A'"); + assert.equal(matches[1], "'B'"); + assert.equal(matches[2], "'B'"); + assert.equal(matches[3], "'A'"); + }); + var cont = yield win.Zotero_Preferences.Sync.checkUser(2, "B"); + assert.isTrue(cont); + + assert.equal(Zotero.Users.getCurrentUserID(), 2); + assert.equal(Zotero.Users.getCurrentUsername(), "B"); + }) + + it("should prompt for user update and cancel", function* () { + yield Zotero.Users.setCurrentUserID(1); + yield Zotero.Users.setCurrentUsername("A"); + + waitForDialog(false, 'cancel'); + var cont = yield win.Zotero_Preferences.Sync.checkUser(2, "B"); + assert.isFalse(cont); + + assert.equal(Zotero.Users.getCurrentUserID(), 1); + assert.equal(Zotero.Users.getCurrentUsername(), "A"); + }) + + it("should update local relations when syncing for the first time", function* () { + yield resetDB({ + thisArg: this, + skipBundledFiles: true + }); + + var item1 = yield createDataObject('item'); + var item2 = yield createDataObject( + 'item', { libraryID: Zotero.Libraries.publicationsLibraryID } + ); + + yield item1.addLinkedItem(item2); + + var cont = yield win.Zotero_Preferences.Sync.checkUser(1, "A"); + assert.isTrue(cont); + + var json = yield item1.toJSON(); + var uri = json.relations[Zotero.Relations.linkedObjectPredicate][0]; + assert.notInclude(uri, 'users/local'); + assert.include(uri, 'users/1/publications'); + }) + }) + +}) + diff --git a/test/tests/syncRunnerTest.js b/test/tests/syncRunnerTest.js @@ -167,10 +167,8 @@ describe("Zotero.Sync.Runner", function () { describe("#checkAccess()", function () { it("should check key access", function* () { - spy = sinon.spy(runner, "checkUser"); setResponse('keyInfo.fullAccess'); var json = yield runner.checkAccess(runner.getAPIClient({ apiKey })); - sinon.assert.calledWith(spy, 1, "Username"); var compare = {}; Object.assign(compare, responses.keyInfo.fullAccess.json); delete compare.key; @@ -409,60 +407,7 @@ describe("Zotero.Sync.Runner", function () { assert.isTrue(Zotero.Groups.exists(groupData.json.id)); }) }) - - describe("#checkUser()", function () { - it("should prompt for user update and perform on accept", function* () { - waitForDialog(function (dialog) { - var text = dialog.document.documentElement.textContent; - var matches = text.match(/'[^']*'/g); - assert.equal(matches.length, 4); - assert.equal(matches[0], "'A'"); - assert.equal(matches[1], "'B'"); - assert.equal(matches[2], "'B'"); - assert.equal(matches[3], "'A'"); - }); - var cont = yield runner.checkUser(2, "B"); - assert.isTrue(cont); - - assert.equal(Zotero.Users.getCurrentUserID(), 2); - assert.equal(Zotero.Users.getCurrentUsername(), "B"); - }) - - it("should prompt for user update and cancel", function* () { - yield Zotero.Users.setCurrentUserID(1); - yield Zotero.Users.setCurrentUsername("A"); - - waitForDialog(false, 'cancel'); - var cont = yield runner.checkUser(2, "B"); - assert.isFalse(cont); - - assert.equal(Zotero.Users.getCurrentUserID(), 1); - assert.equal(Zotero.Users.getCurrentUsername(), "A"); - }) - - it("should update local relations when syncing for the first time", function* () { - yield resetDB({ - thisArg: this, - skipBundledFiles: true - }); - - var item1 = yield createDataObject('item'); - var item2 = yield createDataObject( - 'item', { libraryID: Zotero.Libraries.publicationsLibraryID } - ); - - yield item1.addLinkedItem(item2); - - var cont = yield runner.checkUser(1, "A"); - assert.isTrue(cont); - - var json = yield item1.toJSON(); - var uri = json.relations[Zotero.Relations.linkedObjectPredicate][0]; - assert.notInclude(uri, 'users/local'); - assert.include(uri, 'users/1/publications'); - }) - }) - + describe("#sync()", function () { before(function* () { yield resetDB({ @@ -718,4 +663,69 @@ describe("Zotero.Sync.Runner", function () { assert.isBelow(lastSyncTime, new Date().getTime()); }) }) + + describe("#createAPIKeyFromCredentials()", function() { + var data = { + name: "Automatic Zotero Client Key", + username: "Username", + access: { + user: { + library: true, + files: true, + notes: true, + write: true + }, + groups: { + all: { + library: true, + write: true + } + } + } + }; + var correctPostData = Object.assign({password: 'correctPassword'}, data); + var incorrectPostData = Object.assign({password: 'incorrectPassword'}, data); + var responseData = Object.assign({userID: 1, key: apiKey}, data); + + it("should return json with key when credentials valid", function* () { + server.respond(function (req) { + if (req.method == "POST") { + var json = JSON.parse(req.requestBody); + assert.deepEqual(json, correctPostData); + req.respond(201, {}, JSON.stringify(responseData)); + } + }); + + var json = yield runner.createAPIKeyFromCredentials('Username', 'correctPassword'); + assert.equal(json.key, apiKey); + }); + + it("should return false when credentials invalid", function* () { + server.respond(function (req) { + if (req.method == "POST") { + var json = JSON.parse(req.requestBody); + assert.deepEqual(json, incorrectPostData); + req.respond(403); + } + }); + + var key = yield runner.createAPIKeyFromCredentials('Username', 'incorrectPassword'); + assert.isFalse(key); + }); + }); + + describe("#deleteAPIKey()", function() { + it("should send DELETE request with correct key", function* (){ + Zotero.Sync.Data.Local.setAPIKey(apiKey); + + server.respond(function (req) { + if (req.method == "DELETE") { + assert.equal(req.url, baseURL + "keys/" + apiKey); + } + req.respond(204); + }); + + yield runner.deleteAPIKey(); + }); + }) })