Restores sync credential functionality of 4.0.

Improves UX of sync authentication.
The account is now linked and unlinked and an API key related to
the client is generated transparently in the background.
The API key is deleted on unlinking.
No sync options are allowed before linking an account.
This commit is contained in:
Adomas Venčkauskas 2015-12-02 16:13:29 +00:00
parent 3fcfba5d36
commit ffd9a07164
12 changed files with 826 additions and 382 deletions

View file

@ -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) {

View file

@ -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.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;"/>
<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; }"/>
<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;"/>
<textbox id="sync-password" type="password"
onchange="Zotero.Sync.Server.password = this.value"/>
<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>
<label value="API Key (temp)"/>
<textbox id="sync-api-key" maxlength="24" size="25"
onchange="Zotero.Sync.Data.Local.setAPIKey(this.value)"/>
</row>
<row>
<box/>
<!--<checkbox label="&zotero.preferences.sync.syncAutomatically;" preference="pref-sync-autosync"/>-->
<checkbox label="&zotero.preferences.sync.syncAutomatically;"
disabled="true"/>
</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>
</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"/>
<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"/>
</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>
</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>
</vbox>
</groupbox>
<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>
</vbox>
</tabpanel>
<tabpanel id="zotero-reset" orient="vertical">

View file

@ -54,7 +54,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);
@ -73,6 +81,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>
@ -190,7 +199,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;

View file

@ -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;

View file

@ -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");

View file

@ -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;
});
@ -425,108 +417,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
*
@ -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 "";
})
}

View file

@ -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">

View file

@ -820,12 +820,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:

View file

@ -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;

View file

@ -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>

View file

@ -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');
})
})
})

View file

@ -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();
});
})
})