diff --git a/app/assets/javascripts/app/app.frontend.js b/app/assets/javascripts/app/app.frontend.js index 9563cc6fe..60ec42208 100644 --- a/app/assets/javascripts/app/app.frontend.js +++ b/app/assets/javascripts/app/app.frontend.js @@ -1,6 +1,7 @@ 'use strict'; var Neeto = Neeto || {}; +var SN = SN || {}; // detect IE8 and above, and edge. // IE and Edge do not support pbkdf2 in WebCrypto, therefore we need to use CryptoJS @@ -19,18 +20,4 @@ angular.module('app.frontend', [ .config(function (RestangularProvider, apiControllerProvider) { RestangularProvider.setDefaultHeaders({"Content-Type": "application/json"}); - - RestangularProvider.setFullRequestInterceptor(function(element, operation, route, url, headers, params, httpConfig) { - var token = localStorage.getItem("jwt"); - if(token) { - headers = _.extend(headers, {Authorization: "Bearer " + localStorage.getItem("jwt")}); - } - - return { - element: element, - params: params, - headers: headers, - httpConfig: httpConfig - }; - }); }) diff --git a/app/assets/javascripts/app/frontend/controllers/editor.js b/app/assets/javascripts/app/frontend/controllers/editor.js index a4eeafd21..d0e1ae539 100644 --- a/app/assets/javascripts/app/frontend/controllers/editor.js +++ b/app/assets/javascripts/app/frontend/controllers/editor.js @@ -19,9 +19,9 @@ angular.module('app.frontend') /** * Insert 4 spaces when a tab key is pressed, * only used when inside of the text editor. - * If the shift key is pressed first, this event is - * not fired. - */ + * If the shift key is pressed first, this event is + * not fired. + */ var handleTab = function (event) { if (!event.shiftKey && event.which == 9) { event.preventDefault(); @@ -29,13 +29,13 @@ angular.module('app.frontend') var end = this.selectionEnd; var spaces = " "; - // Insert 4 spaces + // Insert 4 spaces this.value = this.value.substring(0, start) + spaces + this.value.substring(end); - // Place cursor 4 spaces away from where - // the tab key was pressed - this.selectionStart = this.selectionEnd = start + 4; + // Place cursor 4 spaces away from where + // the tab key was pressed + this.selectionStart = this.selectionEnd = start + 4; } } @@ -88,7 +88,7 @@ angular.module('app.frontend') } } }) - .controller('EditorCtrl', function ($sce, $timeout, apiController, markdownRenderer, $rootScope, extensionManager) { + .controller('EditorCtrl', function ($sce, $timeout, apiController, markdownRenderer, $rootScope, extensionManager, syncManager) { this.setNote = function(note, oldNote) { this.editorMode = 'edit'; @@ -148,12 +148,18 @@ angular.module('app.frontend') if(success) { if(statusTimeout) $timeout.cancel(statusTimeout); statusTimeout = $timeout(function(){ - this.noteStatus = "All changes saved" + this.saveError = false; + var status = "All changes saved" + if(syncManager.offline) { + status += " (offline)"; + } + this.noteStatus = status; }.bind(this), 200) } else { if(statusTimeout) $timeout.cancel(statusTimeout); statusTimeout = $timeout(function(){ - this.noteStatus = "(Offline) — All changes saved" + this.saveError = true; + this.noteStatus = "Error saving" }.bind(this), 200) } }.bind(this)); diff --git a/app/assets/javascripts/app/frontend/controllers/header.js b/app/assets/javascripts/app/frontend/controllers/header.js index 99b901b80..822e788da 100644 --- a/app/assets/javascripts/app/frontend/controllers/header.js +++ b/app/assets/javascripts/app/frontend/controllers/header.js @@ -12,6 +12,10 @@ angular.module('app.frontend') link:function(scope, elem, attrs, ctrl) { scope.$on("sync:updated_token", function(){ ctrl.syncUpdated(); + ctrl.findErrors(); + }) + scope.$on("sync:error", function(){ + ctrl.findErrors(); }) } } @@ -19,9 +23,16 @@ angular.module('app.frontend') .controller('HeaderCtrl', function (apiController, modelManager, $timeout, dbManager, syncManager) { this.user = apiController.user; + this.offline = syncManager.offline; + + this.findErrors = function() { + this.error = syncManager.syncProviders.filter(function(provider){return provider.error}).length > 0 ? true : false; + } + this.findErrors(); + this.accountMenuPressed = function() { - this.serverData = {url: syncManager.serverURL()}; + this.serverData = {}; this.showAccountMenu = !this.showAccountMenu; this.showFaq = false; this.showNewPasswordForm = false; diff --git a/app/assets/javascripts/app/services/apiController.js b/app/assets/javascripts/app/services/apiController.js index bfd1b4007..fc847f9fc 100644 --- a/app/assets/javascripts/app/services/apiController.js +++ b/app/assets/javascripts/app/services/apiController.js @@ -7,11 +7,11 @@ angular.module('app.frontend') return domain; } - this.$get = function($rootScope, Restangular, modelManager, dbManager, keyManager, syncManager) { - return new ApiController($rootScope, Restangular, modelManager, dbManager, keyManager, syncManager); + this.$get = function($rootScope, Restangular, modelManager, dbManager, syncManager) { + return new ApiController($rootScope, Restangular, modelManager, dbManager, syncManager); } - function ApiController($rootScope, Restangular, modelManager, dbManager, keyManager, syncManager) { + function ApiController($rootScope, Restangular, modelManager, dbManager, syncManager) { var userData = localStorage.getItem("user"); if(userData) { @@ -79,7 +79,7 @@ angular.module('app.frontend') var params = {password: keys.pw, email: email}; _.merge(request, params); request.post().then(function(response){ - this.handleAuthResponse(response, url, authParams, mk); + this.handleAuthResponse(response, email, url, authParams, mk); callback(response); }.bind(this)) .catch(function(response){ @@ -90,13 +90,16 @@ angular.module('app.frontend') }.bind(this)) } - this.handleAuthResponse = function(response, url, authParams, mk) { - localStorage.setItem("server", url); - localStorage.setItem("jwt", response.token); - localStorage.setItem("user", JSON.stringify(response.user)); - localStorage.setItem("auth_params", JSON.stringify(_.omit(authParams, ["pw_nonce"]))); - keyManager.addKey(SNKeyName, mk); - syncManager.addStandardFileSyncProvider(url); + this.handleAuthResponse = function(response, email, url, authParams, mk) { + var params = { + url: url, + email: email, + uuid: response.user.uuid, + ek: mk, + jwt: response.token, + auth_params: _.omit(authParams, ["pw_nonce"]) + } + syncManager.addAccountBasedSyncProvider(params); } this.register = function(url, email, password, callback) { @@ -107,7 +110,7 @@ angular.module('app.frontend') var params = _.merge({password: keys.pw, email: email}, authParams); _.merge(request, params); request.post().then(function(response){ - this.handleAuthResponse(response, url, authParams, mk); + this.handleAuthResponse(response, email, url, authParams, mk); callback(response); }.bind(this)) .catch(function(response){ @@ -256,19 +259,6 @@ angular.module('app.frontend') return JSON.parse(JSON.stringify(object)); } - this.signoutOfStandardFile = function(destroyAll, callback) { - syncManager.removeStandardFileSyncProvider(); - if(destroyAll) { - this.destroyLocalData(callback); - } else { - localStorage.removeItem("user"); - localStorage.removeItem("jwt"); - localStorage.removeItem("server"); - localStorage.removeItem("auth_params"); - callback(); - } - } - this.destroyLocalData = function(callback) { dbManager.clearAllItems(function(){ localStorage.clear(); diff --git a/app/assets/javascripts/app/services/directives/accountDataMenu.js b/app/assets/javascripts/app/services/directives/accountDataMenu.js index 2d5c5fedf..244101b28 100644 --- a/app/assets/javascripts/app/services/directives/accountDataMenu.js +++ b/app/assets/javascripts/app/services/directives/accountDataMenu.js @@ -3,8 +3,7 @@ class AccountDataMenu { constructor() { this.restrict = "E"; this.templateUrl = "frontend/directives/account-data-menu.html"; - this.scope = { - }; + this.scope = {}; } controller($scope, apiController, modelManager, keyManager) { @@ -13,7 +12,7 @@ class AccountDataMenu { $scope.keys = keyManager.keys; $scope.destroyLocalData = function() { - if(!confirm("Are you sure you want to end your session? This will delete all local items, sync providers, keys, and extensions.")) { + if(!confirm("Are you sure you want to end your session? This will delete all local items, sync accounts, keys, and extensions.")) { return; } diff --git a/app/assets/javascripts/app/services/directives/accountNewAccountSection.js b/app/assets/javascripts/app/services/directives/accountNewAccountSection.js index fceb4e092..b11bb571e 100644 --- a/app/assets/javascripts/app/services/directives/accountNewAccountSection.js +++ b/app/assets/javascripts/app/services/directives/accountNewAccountSection.js @@ -10,7 +10,7 @@ class AccountNewAccountSection { controller($scope, apiController, modelManager, $timeout, dbManager, syncManager) { 'ngInject'; - $scope.formData = {mergeLocal: true, url: syncManager.serverURL()}; + $scope.formData = {mergeLocal: true, url: syncManager.defaultServerURL()}; $scope.user = apiController.user; $scope.showForm = syncManager.syncProviders.length == 0; @@ -20,15 +20,10 @@ class AccountNewAccountSection { } $scope.submitExternalSyncURL = function() { - syncManager.addSyncProviderFromURL($scope.newSyncData.url); - $scope.newSyncData.showAddSyncForm = false; - } - - $scope.signOutPressed = function() { - $scope.showAccountMenu = false; - apiController.signoutOfStandardFile(false, function(){ - window.location.reload(); - }) + syncManager.addSyncProviderFromURL($scope.formData.secretUrl); + $scope.formData.showAddLinkForm = false; + $scope.formData.secretUrl = null; + $scope.showForm = false; } $scope.submitPasswordChange = function() { diff --git a/app/assets/javascripts/app/services/directives/accountSyncSection.js b/app/assets/javascripts/app/services/directives/accountSyncSection.js index c9009d772..630c416a2 100644 --- a/app/assets/javascripts/app/services/directives/accountSyncSection.js +++ b/app/assets/javascripts/app/services/directives/accountSyncSection.js @@ -16,7 +16,7 @@ class AccountSyncSection { $scope.enableSyncProvider = function(provider, primary) { if(!provider.keyName) { - alert("You must choose an encryption key for this provider before enabling it."); + alert("You must choose an encryption key for this account before enabling it."); return; } @@ -24,19 +24,19 @@ class AccountSyncSection { } $scope.removeSyncProvider = function(provider) { - if(provider.isStandardNotesAccount) { - alert("To remove your Standard Notes sync, sign out of your Standard Notes account.") + if(provider.primary) { + alert("You cannot remove your main sync account. Instead, end your session by destroying all local data. Or, choose another account to be your primary sync account.") return; } - if(confirm("Are you sure you want to remove this sync provider?")) { + if(confirm("Are you sure you want to remove this sync account?")) { syncManager.removeSyncProvider(provider); } } $scope.changeEncryptionKey = function(provider) { if(provider.isStandardNotesAccount) { - alert("To change your encryption key for your Standard Notes account, you need to change your password. However, this functionality is not currently supported."); + alert("To change your encryption key for your Standard File account, you need to change your password. However, this functionality is not currently available."); return; } diff --git a/app/assets/javascripts/app/services/directives/delay-hide.js b/app/assets/javascripts/app/services/directives/delay-hide.js index 732e9b4f0..7467208f4 100644 --- a/app/assets/javascripts/app/services/directives/delay-hide.js +++ b/app/assets/javascripts/app/services/directives/delay-hide.js @@ -16,7 +16,6 @@ angular // Whenever the scope variable updates we simply // show if it evaluates to 'true' and hide if 'false' scope.$watch('show', function(newVal){ - console.log("show value changed", newVal); newVal ? showSpinner() : hideSpinner(); }); @@ -29,7 +28,6 @@ angular } function showElement(show) { - console.log("show:", show); show ? elem.css({display:''}) : elem.css({display:'none'}); } diff --git a/app/assets/javascripts/app/services/sync/syncManager.js b/app/assets/javascripts/app/services/sync/syncManager.js index 9b189fc9e..54cb16501 100644 --- a/app/assets/javascripts/app/services/sync/syncManager.js +++ b/app/assets/javascripts/app/services/sync/syncManager.js @@ -2,8 +2,9 @@ export const SNKeyName = "Standard Notes Key"; class SyncManager { - constructor(modelManager, syncRunner) { + constructor(modelManager, syncRunner, keyManager) { this.modelManager = modelManager; + this.keyManager = keyManager; this.syncRunner = syncRunner; this.syncRunner.setOnChangeProviderCallback(function(){ this.didMakeChangesToSyncProviders(); @@ -15,14 +16,19 @@ class SyncManager { return this.enabledProviders.length == 0; } - serverURL() { - return localStorage.getItem("server") || "https://n3.standardnotes.org"; + defaultServerURL() { + return "https://n3.standardnotes.org"; } get enabledProviders() { return this.syncProviders.filter(function(provider){return provider.enabled == true}); } + /* Used when adding a new account with */ + markAllOfflineItemsDirtyAndSave() { + + } + sync(callback) { this.syncRunner.sync(this.enabledProviders, callback); } @@ -57,21 +63,6 @@ class SyncManager { return _.find(this.syncProviders, {primary: true}); } - removeStandardFileSyncProvider() { - var sfProvider = _.find(this.syncProviders, {url: this.serverURL() + "/items/sync"}) - _.pull(this.syncProviders, sfProvider); - this.didMakeChangesToSyncProviders(); - } - - addStandardFileSyncProvider(url) { - var defaultProvider = new SyncProvider({url: url + "/items/sync", primary: !this.primarySyncProvider()}); - defaultProvider.keyName = SNKeyName; - defaultProvider.enabled = this.syncProviders.length == 0; - this.syncProviders.push(defaultProvider); - this.didMakeChangesToSyncProviders(); - return defaultProvider; - } - didMakeChangesToSyncProviders() { localStorage.setItem("syncProviders", JSON.stringify(_.map(this.syncProviders, function(provider) { return provider.asJSON() @@ -89,22 +80,58 @@ class SyncManager { } else { // no providers saved, this means migrating from old system to new // check if user is signed in - if(this.offline && localStorage.getItem("user")) { - var defaultProvider = this.addStandardFileSyncProvider(this.serverURL()); - defaultProvider.syncToken = localStorage.getItem("syncToken"); - // migrate old key structure to new - var mk = localStorage.getItem("mk"); - if(mk) { - keyManager.addKey(SNKeyName, mk); - localStorage.removeItem("mk"); + var userJSON = localStorage.getItem("user"); + if(this.offline && userJSON) { + var user = JSON.parse(userJSON); + var params = { + url: localStorage.getItem("server"), + email: user.email, + uuid: user.uuid, + ek: localStorage.getItem("mk"), + jwt: response.token, + auth_params: JSON.parse(localStorage.getItem("auth_params")), } + var defaultProvider = this.addAccountBasedSyncProvider(params); + defaultProvider.syncToken = localStorage.getItem("syncToken"); + localStorage.removeItem("mk"); + localStorage.removeItem("syncToken"); + localStorage.removeItem("auth_params"); + localStorage.removeItem("user"); + localStorage.removeItem("server"); this.didMakeChangesToSyncProviders(); } } } + addAccountBasedSyncProvider({url, email, uuid, ek, jwt, auth_params} = {}) { + var provider = new SyncProvider({ + url: url + "/items/sync", + primary: !this.primarySyncProvider(), + email: email, + uuid: uuid, + jwt: jwt, + auth_params: auth_params, + type: SN.SyncProviderType.account + }); + + provider.keyName = provider.name; + + this.syncProviders.push(provider); + + this.didMakeChangesToSyncProviders(); + + this.keyManager.addKey(provider.keyName, ek); + + if(this.syncProviders.length == 0) { + this.enableSyncProvider(provider, true); + } + + return provider; + } + addSyncProviderFromURL(url) { var provider = new SyncProvider({url: url}); + provider.type = SN.SyncProviderType.URL; this.syncProviders.push(provider); this.didMakeChangesToSyncProviders(); } @@ -123,8 +150,6 @@ class SyncManager { this.addAllDataAsNeedingSyncForProvider(syncProvider); this.didMakeChangesToSyncProviders(); this.syncWithProvider(syncProvider); - this.syncWithProvider(syncProvider); - this.syncWithProvider(syncProvider); } addAllDataAsNeedingSyncForProvider(syncProvider) { diff --git a/app/assets/javascripts/app/services/sync/syncProvider.js b/app/assets/javascripts/app/services/sync/syncProvider.js index 0e197e8d9..c9dceb4e4 100644 --- a/app/assets/javascripts/app/services/sync/syncProvider.js +++ b/app/assets/javascripts/app/services/sync/syncProvider.js @@ -1,3 +1,8 @@ +SN.SyncProviderType = { + Account: 1, + URL: 2 +} + class SyncProvider { constructor(obj) { @@ -31,13 +36,28 @@ class SyncProvider { else return "secondary"; } + get name() { + if(this.type == SN.SyncProviderType.account) { + return this.email + "@" + this.url; + } else { + return this.url; + } + } + asJSON() { return { enabled: this.enabled, url: this.url, + type: this.type, primary: this.primary, keyName: this.keyName, - syncToken: this.syncToken + syncToken: this.syncToken, + + // account based + email: this.email, + uuid: this.uuid, + jwt: this.jwt, + auth_params: this.auth_params } } } diff --git a/app/assets/javascripts/app/services/sync/syncRunner.js b/app/assets/javascripts/app/services/sync/syncRunner.js index 4172e3a8e..f79b7c694 100644 --- a/app/assets/javascripts/app/services/sync/syncRunner.js +++ b/app/assets/javascripts/app/services/sync/syncRunner.js @@ -39,7 +39,6 @@ class SyncRunner { } syncOffline(items, callback) { - console.log("Writing items offline", items); this.writeItemsToLocalStorage(items, true, function(responseItems){ // delete anything needing to be deleted for(var item of items) { @@ -128,7 +127,9 @@ class SyncRunner { request.sync_token = provider.syncToken; request.cursor_token = provider.cursorToken; - request.post().then(function(response) { + var headers = provider.jwt ? {Authorization: "Bearer " + provider.jwt} : {}; + request.post("", undefined, undefined, headers).then(function(response) { + provider.error = null; if(!provider.primary) { console.log("Completed sync for provider:", provider.url, "Response:", response); @@ -170,15 +171,19 @@ class SyncRunner { }.bind(this)) .catch(function(response){ console.log("Sync error: ", response); + var error = response.data.error || {message: "Could not connect to server."}; // Re-add subItems since this operation failed. We'll have to try again. provider.addPendingItems(subItems); provider.syncOpInProgress = false; + provider.error = error; if(provider.primary) { this.writeItemsToLocalStorage(allItems, false, null); } + this.rootScope.$broadcast("sync:error", error); + if(callback) { callback({error: "Sync error"}); } diff --git a/app/assets/stylesheets/app/_header.scss b/app/assets/stylesheets/app/_header.scss index a69286884..00729ed13 100644 --- a/app/assets/stylesheets/app/_header.scss +++ b/app/assets/stylesheets/app/_header.scss @@ -14,6 +14,10 @@ margin-top: 10px; } +.mt-15 { + margin-top: 15px; +} + .faded { opacity: 0.5; } @@ -74,11 +78,15 @@ section { padding: 5px; + padding-bottom: 2px; margin-top: 5px; &.inline-h { - padding: 5px 0px; + padding-top: 5px; + padding-left: 0; + padding-right: 0; } + } input { @@ -121,6 +129,10 @@ .large-padding { padding: 15px; } + + .red { + color: red; + } } .footer-bar-link { diff --git a/app/assets/templates/frontend/directives/account-menu/account-new-account-section.html.haml b/app/assets/templates/frontend/directives/account-menu/account-new-account-section.html.haml index 758c4b570..94ed496b3 100644 --- a/app/assets/templates/frontend/directives/account-menu/account-new-account-section.html.haml +++ b/app/assets/templates/frontend/directives/account-menu/account-new-account-section.html.haml @@ -23,7 +23,7 @@ %label.center-align.block.faded — OR — %a.block.center-align.medium-text{"ng-if" => "!formData.showAddLinkForm", "ng-click" => "formData.showAddLinkForm = true"} Add sync using secret link %form{"ng-if" => "formData.showAddLinkForm"} - %input.form-control{:autofocus => 'autofocus', :name => 'url', :placeholder => 'Secret URL', :required => true, :type => 'url', 'ng-model' => 'formData.url'} + %input.form-control{:autofocus => 'autofocus', :name => 'url', :placeholder => 'Secret URL', :required => true, :type => 'url', 'ng-model' => 'formData.secretUrl'} %button.btn.dark-button.btn-block{"ng-click" => "submitExternalSyncURL()"} Add Sync Account %a.block.center-align.mt-5{"ng-click" => "formData.showAddLinkForm = false"} Cancel diff --git a/app/assets/templates/frontend/directives/account-menu/account-sync-section.html.haml b/app/assets/templates/frontend/directives/account-menu/account-sync-section.html.haml index 6d430bd91..d1ca0da2e 100644 --- a/app/assets/templates/frontend/directives/account-menu/account-sync-section.html.haml +++ b/app/assets/templates/frontend/directives/account-menu/account-sync-section.html.haml @@ -3,7 +3,7 @@ %div{"ng-if" => "showSection"} .small-v-space - %section.white-bg{"ng-repeat" => "provider in syncProviders"} + %section.white-bg.medium-padding{"ng-repeat" => "provider in syncProviders"} %label {{!provider.enabled ? 'Not enabled' : (provider.primary ? 'Main' : 'Secondary')}} %em{"ng-if" => "provider.keyName"} Using key: {{provider.keyName}} %p {{provider.url}} @@ -16,11 +16,15 @@ {{key.name}} %button{"ng-click" => "saveKey(provider)"} Set - %div{"ng-if" => "!provider.enabled"} - %button.light{"ng-click" => "enableSyncProvider(provider, true)"} Set as Main - %button.light{"ng-if" => "syncProviders.length > 1", "ng-click" => "enableSyncProvider(provider, false)"} Add as Secondary + %button.light{"ng-if" => "!provider.enabled || !provider.primary", "ng-click" => "enableSyncProvider(provider, true)"} Set as Main + %button.light{"ng-if" => "syncProviders.length > 1 && (provider.primary || !provider.enabled)", "ng-click" => "enableSyncProvider(provider, false)"} Add as Secondary + %button.light{"ng-if" => "provider.keyName", "ng-click" => "changeEncryptionKey(provider)"} Change Encryption Key - %button.light{"ng-click" => "removeSyncProvider(provider)"} Remove Provider - %div{"style" => "height: 30px;", "delay-hide" => "true", "show" => "provider.syncOpInProgress", "delay" => "1000"} - %strong{"style" => "float: left;"} Syncing: {{provider.syncStatus.statusString}} - .spinner{"style" => "float: right"} + %button.light{"ng-click" => "removeSyncProvider(provider)"} Remove Account + + .mt-15{"ng-if" => "provider.error"} + %strong.red Error syncing: {{provider.error.message}} + .mt-15{"style" => "height: 15px;", "delay-hide" => "true", "show" => "provider.syncOpInProgress", "delay" => "1000"} + .spinner{"style" => "float: left; margin-top: 3px; margin-left: 2px;"} + %strong{"style" => "float: left; margin-left: 7px;"} Syncing: +   {{provider.syncStatus.statusString}} diff --git a/app/assets/templates/frontend/header.html.haml b/app/assets/templates/frontend/header.html.haml index a9d7371cb..f1a72f35f 100644 --- a/app/assets/templates/frontend/header.html.haml +++ b/app/assets/templates/frontend/header.html.haml @@ -1,7 +1,7 @@ .footer-bar .pull-left .footer-bar-link - %a{"ng-click" => "ctrl.accountMenuPressed()"} Account + %a{"ng-click" => "ctrl.accountMenuPressed()", "ng-class" => "{red: ctrl.error}"} Account %account-data-menu{"ng-if" => "ctrl.showAccountMenu"} .footer-bar-link @@ -14,11 +14,12 @@ .pull-right - .footer-bar-link + .footer-bar-link{"style" => "margin-right: 5px;"} %div{"ng-if" => "ctrl.lastSyncDate", "style" => "float: left; font-weight: normal; margin-right: 8px;"} %span{"ng-if" => "!ctrl.isRefreshing"} Last refreshed {{ctrl.lastSyncDate | appDateTime}} %span{"ng-if" => "ctrl.isRefreshing"} .spinner{"style" => "margin-top: 2px;"} - %a{"ng-click" => "ctrl.refreshData()"} Refresh + %strong{"ng-if" => "ctrl.offline"} Offline + %a{"ng-if" => "!ctrl.offline", "ng-click" => "ctrl.refreshData()"} Refresh