Added google helper
This commit is contained in:
parent
855e6cb056
commit
1e74fb00dc
@ -11,7 +11,7 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="navigation-bar__inner navigation-bar__inner--right flex flex--row">
|
<div class="navigation-bar__inner navigation-bar__inner--right flex flex--row">
|
||||||
<div class="navigation-bar__spinner">
|
<div class="navigation-bar__spinner" v-show="showSpinner">
|
||||||
<div class="spinner"></div>
|
<div class="spinner"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="navigation-bar__title navigation-bar__title--fake text-input"></div>
|
<div class="navigation-bar__title navigation-bar__title--fake text-input"></div>
|
||||||
@ -75,6 +75,9 @@ export default {
|
|||||||
...mapGetters('layout', [
|
...mapGetters('layout', [
|
||||||
'styles',
|
'styles',
|
||||||
]),
|
]),
|
||||||
|
showSpinner() {
|
||||||
|
return !this.$store.state.queue.isEmpty;
|
||||||
|
},
|
||||||
titleWidth() {
|
titleWidth() {
|
||||||
if (!this.mounted) {
|
if (!this.mounted) {
|
||||||
return 0;
|
return 0;
|
||||||
|
@ -13,16 +13,16 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="side-bar__inner">
|
<div class="side-bar__inner">
|
||||||
<div v-if="panel === 'menu'" class="side-bar__panel side-bar__panel--menu">
|
<div v-if="panel === 'menu'" class="side-bar__panel side-bar__panel--menu">
|
||||||
<side-bar-item @click.native="signin">
|
<side-bar-item v-if="!loginToken" @click.native="signin">
|
||||||
<icon-login slot="icon"></icon-login>
|
<icon-login slot="icon"></icon-login>
|
||||||
<div>Sign in with Google</div>
|
<div>Sign in with Google</div>
|
||||||
<span>Have all your files and settings backed up and synced.</span>
|
<span>Have all your files and settings backed up and synced.</span>
|
||||||
</side-bar-item>
|
</side-bar-item>
|
||||||
<side-bar-item @click.native="signin">
|
<!-- <side-bar-item @click.native="signin">
|
||||||
<icon-login slot="icon"></icon-login>
|
<icon-login slot="icon"></icon-login>
|
||||||
<div>Sign in on CouchDB</div>
|
<div>Sign in on CouchDB</div>
|
||||||
<span>Save and collaborate on a CouchDB hosted by you.</span>
|
<span>Save and collaborate on a CouchDB hosted by you.</span>
|
||||||
</side-bar-item>
|
</side-bar-item> -->
|
||||||
<side-bar-item @click.native="panel = 'toc'">
|
<side-bar-item @click.native="panel = 'toc'">
|
||||||
<icon-toc slot="icon"></icon-toc>
|
<icon-toc slot="icon"></icon-toc>
|
||||||
Table of contents
|
Table of contents
|
||||||
@ -44,12 +44,13 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { mapActions } from 'vuex';
|
import { mapGetters, mapActions } from 'vuex';
|
||||||
import Toc from './Toc';
|
import Toc from './Toc';
|
||||||
import SideBarItem from './SideBarItem';
|
import SideBarItem from './SideBarItem';
|
||||||
import markdownSample from '../data/markdownSample.md';
|
import markdownSample from '../data/markdownSample.md';
|
||||||
import markdownConversionSvc from '../services/markdownConversionSvc';
|
import markdownConversionSvc from '../services/markdownConversionSvc';
|
||||||
import userSvc from '../services/userSvc';
|
import googleHelper from '../services/helpers/googleHelper';
|
||||||
|
import syncSvc from '../services/syncSvc';
|
||||||
|
|
||||||
const panelNames = {
|
const panelNames = {
|
||||||
menu: 'Menu',
|
menu: 'Menu',
|
||||||
@ -72,6 +73,9 @@ export default {
|
|||||||
markdownSample: markdownConversionSvc.highlight(markdownSample),
|
markdownSample: markdownConversionSvc.highlight(markdownSample),
|
||||||
}),
|
}),
|
||||||
computed: {
|
computed: {
|
||||||
|
...mapGetters('data', [
|
||||||
|
'loginToken',
|
||||||
|
]),
|
||||||
panelName() {
|
panelName() {
|
||||||
return panelNames[this.panel];
|
return panelNames[this.panel];
|
||||||
},
|
},
|
||||||
@ -81,7 +85,10 @@ export default {
|
|||||||
'toggleSideBar',
|
'toggleSideBar',
|
||||||
]),
|
]),
|
||||||
signin() {
|
signin() {
|
||||||
userSvc.signinWithGoogle();
|
googleHelper.startOauth2([
|
||||||
|
'openid',
|
||||||
|
'https://www.googleapis.com/auth/drive.appdata',
|
||||||
|
]).then(() => syncSvc.requestSync());
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@ -111,6 +118,10 @@ export default {
|
|||||||
left: 1000px;
|
left: 1000px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.side-bar__panel--menu {
|
||||||
|
padding: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
.side-bar__panel--help {
|
.side-bar__panel--help {
|
||||||
padding: 0 10px 40px 20px;
|
padding: 0 10px 40px 20px;
|
||||||
|
|
||||||
|
@ -14,7 +14,6 @@
|
|||||||
text-align: left;
|
text-align: left;
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
height: auto;
|
height: auto;
|
||||||
margin: 5px;
|
|
||||||
|
|
||||||
span {
|
span {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
|
@ -1,6 +1,5 @@
|
|||||||
import Vue from 'vue';
|
import Vue from 'vue';
|
||||||
import './extensions/';
|
import './extensions/';
|
||||||
import './services/syncSvc';
|
|
||||||
import './services/optional';
|
import './services/optional';
|
||||||
import './icons/';
|
import './icons/';
|
||||||
import App from './components/App';
|
import App from './components/App';
|
||||||
|
224
src/services/helpers/googleHelper.js
Normal file
224
src/services/helpers/googleHelper.js
Normal file
@ -0,0 +1,224 @@
|
|||||||
|
import utils from '../utils';
|
||||||
|
import store from '../../store';
|
||||||
|
|
||||||
|
const clientId = '241271498917-t4t7d07qis7oc0ahaskbif3ft6tk63cd.apps.googleusercontent.com';
|
||||||
|
const appsDomain = null;
|
||||||
|
const tokenExpirationMargin = 10 * 60 * 1000; // 10 min
|
||||||
|
|
||||||
|
// const scopeMap = {
|
||||||
|
// profile: [
|
||||||
|
// 'https://www.googleapis.com/auth/userinfo.profile',
|
||||||
|
// ],
|
||||||
|
// gdrive: [
|
||||||
|
// 'https://www.googleapis.com/auth/drive.install',
|
||||||
|
// store.getters['data/settings'].gdriveFullAccess === true ?
|
||||||
|
// 'https://www.googleapis.com/auth/drive' :
|
||||||
|
// 'https://www.googleapis.com/auth/drive.file',
|
||||||
|
// ],
|
||||||
|
// blogger: [
|
||||||
|
// 'https://www.googleapis.com/auth/blogger',
|
||||||
|
// ],
|
||||||
|
// picasa: [
|
||||||
|
// 'https://www.googleapis.com/auth/photos',
|
||||||
|
// ],
|
||||||
|
// };
|
||||||
|
|
||||||
|
const request = (googleToken, options) => utils.request({
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
...options.headers,
|
||||||
|
Authorization: `Bearer ${googleToken.accessToken}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const saveFile = (googleToken, data, appData) => {
|
||||||
|
const options = {
|
||||||
|
method: 'POST',
|
||||||
|
url: 'https://www.googleapis.com/upload/drive/v2/files',
|
||||||
|
headers: {},
|
||||||
|
};
|
||||||
|
if (appData) {
|
||||||
|
options.method = 'PUT';
|
||||||
|
options.url = `https://www.googleapis.com/drive/v2/files/${appData.id}`;
|
||||||
|
options.headers['if-match'] = appData.etag;
|
||||||
|
}
|
||||||
|
const metadata = {
|
||||||
|
title: data.name,
|
||||||
|
parents: [{
|
||||||
|
id: 'appDataFolder',
|
||||||
|
}],
|
||||||
|
properties: Object.keys(data)
|
||||||
|
.filter(key => key !== 'name' && key !== 'tx')
|
||||||
|
.map(key => ({
|
||||||
|
key,
|
||||||
|
value: JSON.stringify(data[key]),
|
||||||
|
visibility: 'PUBLIC',
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
const media = null;
|
||||||
|
const boundary = `-------${utils.uid()}`;
|
||||||
|
const delimiter = `\r\n--${boundary}\r\n`;
|
||||||
|
const closeDelimiter = `\r\n--${boundary}--`;
|
||||||
|
if (media) {
|
||||||
|
let multipartRequestBody = '';
|
||||||
|
multipartRequestBody += delimiter;
|
||||||
|
multipartRequestBody += 'Content-Type: application/json\r\n\r\n';
|
||||||
|
multipartRequestBody += JSON.stringify(metadata);
|
||||||
|
multipartRequestBody += delimiter;
|
||||||
|
multipartRequestBody += 'Content-Type: application/json\r\n\r\n';
|
||||||
|
multipartRequestBody += JSON.stringify(media);
|
||||||
|
multipartRequestBody += closeDelimiter;
|
||||||
|
return request(googleToken, {
|
||||||
|
...options,
|
||||||
|
params: {
|
||||||
|
uploadType: 'multipart',
|
||||||
|
},
|
||||||
|
headers: {
|
||||||
|
...options.headers,
|
||||||
|
'Content-Type': `multipart/mixed; boundary="${boundary}"`,
|
||||||
|
},
|
||||||
|
body: multipartRequestBody,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return request(googleToken, {
|
||||||
|
...options,
|
||||||
|
body: metadata,
|
||||||
|
}).then(res => ({
|
||||||
|
id: res.body.id,
|
||||||
|
etag: res.body.etag,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
export default {
|
||||||
|
startOauth2(scopes, sub = null, silent = false) {
|
||||||
|
return utils.startOauth2(
|
||||||
|
'https://accounts.google.com/o/oauth2/v2/auth', {
|
||||||
|
client_id: clientId,
|
||||||
|
response_type: 'token',
|
||||||
|
scope: scopes.join(' '),
|
||||||
|
hd: appsDomain,
|
||||||
|
login_hint: sub,
|
||||||
|
prompt: silent ? 'none' : null,
|
||||||
|
}, silent)
|
||||||
|
// Call the tokeninfo endpoint
|
||||||
|
.then(data => utils.request({
|
||||||
|
method: 'POST',
|
||||||
|
url: 'https://www.googleapis.com/oauth2/v3/tokeninfo',
|
||||||
|
params: {
|
||||||
|
access_token: data.accessToken,
|
||||||
|
},
|
||||||
|
}).then((res) => {
|
||||||
|
// Check the returned client ID consistency
|
||||||
|
if (res.body.aud !== clientId) {
|
||||||
|
throw new Error('Client ID inconsistent.');
|
||||||
|
}
|
||||||
|
// Check the returned sub consistency
|
||||||
|
if (sub && res.body.sub !== sub) {
|
||||||
|
throw new Error('Google account ID not expected.');
|
||||||
|
}
|
||||||
|
// Build token object including scopes and sub
|
||||||
|
return {
|
||||||
|
scopes,
|
||||||
|
accessToken: data.accessToken,
|
||||||
|
expiresOn: Date.now() + (data.expiresIn * 1000),
|
||||||
|
sub: res.body.sub,
|
||||||
|
isLogin: !store.getters['data/loginToken'],
|
||||||
|
};
|
||||||
|
}))
|
||||||
|
// Call the tokeninfo endpoint
|
||||||
|
.then(googleToken => request(googleToken, {
|
||||||
|
method: 'GET',
|
||||||
|
url: 'https://www.googleapis.com/plus/v1/people/me',
|
||||||
|
}).then((res) => {
|
||||||
|
// Add name to googleToken
|
||||||
|
googleToken.name = res.body.displayName;
|
||||||
|
const existingToken = store.getters['data/googleTokens'][googleToken.sub];
|
||||||
|
if (existingToken) {
|
||||||
|
if (!sub) {
|
||||||
|
throw new Error('Google account already linked.');
|
||||||
|
}
|
||||||
|
// Add isLogin and lastChangeId to googleToken
|
||||||
|
googleToken.isLogin = existingToken.isLogin;
|
||||||
|
googleToken.lastChangeId = existingToken.lastChangeId;
|
||||||
|
}
|
||||||
|
// Add googleToken to googleTokens
|
||||||
|
store.dispatch('data/setGoogleToken', googleToken);
|
||||||
|
return googleToken;
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
refreshToken(scopes, googleToken) {
|
||||||
|
const sub = googleToken.sub;
|
||||||
|
const lastToken = store.getters['data/googleTokens'][sub];
|
||||||
|
const mergedScopes = [...new Set([
|
||||||
|
...scopes,
|
||||||
|
...lastToken.scopes,
|
||||||
|
])];
|
||||||
|
|
||||||
|
return Promise.resolve()
|
||||||
|
.then(() => {
|
||||||
|
if (mergedScopes.length === lastToken.scopes.length) {
|
||||||
|
return lastToken;
|
||||||
|
}
|
||||||
|
// New scopes are requested, popup an authorize window
|
||||||
|
return this.startOauth2(mergedScopes, sub);
|
||||||
|
})
|
||||||
|
.then((refreshedToken) => {
|
||||||
|
if (refreshedToken.expiresOn > Date.now() + tokenExpirationMargin) {
|
||||||
|
// Token is fresh enough
|
||||||
|
return refreshedToken;
|
||||||
|
}
|
||||||
|
// Token is almost outdated, try to take one in background
|
||||||
|
return this.startOauth2(mergedScopes, sub, true)
|
||||||
|
// If it fails try to popup a window
|
||||||
|
.catch(() => this.startOauth2(mergedScopes, sub));
|
||||||
|
});
|
||||||
|
},
|
||||||
|
getChanges(googleToken) {
|
||||||
|
let changes = [];
|
||||||
|
return this.refreshToken(['https://www.googleapis.com/auth/drive.appdata'], googleToken)
|
||||||
|
.then((refreshedToken) => {
|
||||||
|
const lastChangeId = refreshedToken.lastChangeId || 0;
|
||||||
|
const getPage = pageToken => request(refreshedToken, {
|
||||||
|
method: 'GET',
|
||||||
|
url: 'https://www.googleapis.com/drive/v2/changes',
|
||||||
|
params: {
|
||||||
|
pageToken,
|
||||||
|
startChangeId: pageToken || !lastChangeId ? null : lastChangeId + 1,
|
||||||
|
spaces: 'appDataFolder',
|
||||||
|
fields: 'nextPageToken,items(deleted,file/id,file/etag,file/title,file/properties(key,value))',
|
||||||
|
},
|
||||||
|
}).then((res) => {
|
||||||
|
changes = changes.concat(res.body.items);
|
||||||
|
if (res.body.nextPageToken) {
|
||||||
|
return getPage(res.body.nextPageToken);
|
||||||
|
}
|
||||||
|
return changes;
|
||||||
|
});
|
||||||
|
|
||||||
|
return getPage();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
updateLastChangeId(googleToken, changes) {
|
||||||
|
const refreshedToken = store.getters['data/googleTokens'][googleToken.sub];
|
||||||
|
let lastChangeId = refreshedToken.lastChangeId || 0;
|
||||||
|
changes.forEach((change) => {
|
||||||
|
if (change.id > lastChangeId) {
|
||||||
|
lastChangeId = change.id;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (lastChangeId !== refreshedToken.lastChangeId) {
|
||||||
|
store.dispatch('data/setGoogleToken', {
|
||||||
|
...refreshedToken,
|
||||||
|
lastChangeId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
insertData(googleToken, data) {
|
||||||
|
return this.refreshToken(['https://www.googleapis.com/auth/drive.appdata'], googleToken)
|
||||||
|
.then(refreshedToken => saveFile(refreshedToken, data));
|
||||||
|
},
|
||||||
|
updateData(googleToken, data, appData) {
|
||||||
|
return this.refreshToken(['https://www.googleapis.com/auth/drive.appdata'], googleToken)
|
||||||
|
.then(refreshedToken => saveFile(refreshedToken, data, appData));
|
||||||
|
},
|
||||||
|
};
|
@ -2,6 +2,96 @@ import localDbSvc from './localDbSvc';
|
|||||||
import store from '../store';
|
import store from '../store';
|
||||||
import welcomeFile from '../data/welcomeFile.md';
|
import welcomeFile from '../data/welcomeFile.md';
|
||||||
import utils from './utils';
|
import utils from './utils';
|
||||||
|
import userActivitySvc from './userActivitySvc';
|
||||||
|
import googleHelper from './helpers/googleHelper';
|
||||||
|
|
||||||
|
const lastSyncActivityKey = 'lastSyncActivity';
|
||||||
|
let lastSyncActivity;
|
||||||
|
const getStoredLastSyncActivity = () => parseInt(localStorage[lastSyncActivityKey], 10) || 0;
|
||||||
|
const inactivityThreshold = 3 * 1000; // 3 sec
|
||||||
|
const autoSyncAfter = 60 * 1000; // 1 min
|
||||||
|
const isSyncAvailable = () => window.navigator.onLine !== false &&
|
||||||
|
!!store.getters['data/loginToken'];
|
||||||
|
|
||||||
|
function isSyncWindow() {
|
||||||
|
const storedLastSyncActivity = getStoredLastSyncActivity();
|
||||||
|
return lastSyncActivity === storedLastSyncActivity ||
|
||||||
|
Date.now() > inactivityThreshold + storedLastSyncActivity;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAutoSyncNeeded() {
|
||||||
|
const storedLastSyncActivity = getStoredLastSyncActivity();
|
||||||
|
return Date.now() > autoSyncAfter + storedLastSyncActivity;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setLastSyncActivity() {
|
||||||
|
const currentDate = Date.now();
|
||||||
|
lastSyncActivity = currentDate;
|
||||||
|
localStorage[lastSyncActivityKey] = currentDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sync() {
|
||||||
|
const googleToken = store.getters['data/loginToken'];
|
||||||
|
return googleHelper.getChanges(googleToken)
|
||||||
|
.then((changes) => {
|
||||||
|
console.log(changes);
|
||||||
|
const localChanges = [];
|
||||||
|
[
|
||||||
|
store.state.files,
|
||||||
|
].forEach((moduleState) => {
|
||||||
|
Object.keys(moduleState.itemMap).forEach((id) => {
|
||||||
|
localChanges.push(moduleState.itemMap[id]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const uploadLocalChange = () => {
|
||||||
|
const localChange = localChanges.pop();
|
||||||
|
if (!localChange) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return googleHelper.insertAppData(googleToken, localChange)
|
||||||
|
.then(() => uploadLocalChange());
|
||||||
|
};
|
||||||
|
return uploadLocalChange();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestSync() {
|
||||||
|
store.dispatch('queue/enqueueSyncRequest', () => new Promise((resolve, reject) => {
|
||||||
|
let intervalId;
|
||||||
|
const attempt = () => {
|
||||||
|
// Only start syncing when these conditions are met
|
||||||
|
if (userActivitySvc.isActive() && isSyncWindow()) {
|
||||||
|
clearInterval(intervalId);
|
||||||
|
if (!isSyncAvailable()) {
|
||||||
|
// Cancel sync
|
||||||
|
reject();
|
||||||
|
} else {
|
||||||
|
// Call setLastSyncActivity periodically
|
||||||
|
intervalId = utils.setInterval(() => setLastSyncActivity(), 1000);
|
||||||
|
setLastSyncActivity();
|
||||||
|
const cleaner = cb => (res) => {
|
||||||
|
clearInterval(intervalId);
|
||||||
|
cb(res);
|
||||||
|
};
|
||||||
|
sync().then(cleaner(resolve), cleaner(reject));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
intervalId = utils.setInterval(() => attempt(), 1000);
|
||||||
|
attempt();
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync periodically
|
||||||
|
utils.setInterval(() => {
|
||||||
|
if (isSyncAvailable() &&
|
||||||
|
userActivitySvc.isActive() &&
|
||||||
|
isSyncWindow() &&
|
||||||
|
isAutoSyncNeeded()
|
||||||
|
) {
|
||||||
|
requestSync();
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
const ifNoId = cb => (obj) => {
|
const ifNoId = cb => (obj) => {
|
||||||
if (obj.id) {
|
if (obj.id) {
|
||||||
@ -12,33 +102,38 @@ const ifNoId = cb => (obj) => {
|
|||||||
|
|
||||||
// Load the DB on boot
|
// Load the DB on boot
|
||||||
localDbSvc.sync()
|
localDbSvc.sync()
|
||||||
// Watch file changing
|
// And watch file changing
|
||||||
.then(() => store.watch(
|
.then(() => store.watch(
|
||||||
() => store.getters['files/current'].id,
|
() => store.getters['files/current'].id,
|
||||||
() => Promise.resolve(store.getters['files/current'])
|
() => Promise.resolve(store.getters['files/current'])
|
||||||
// If current file has no ID, get the most recent file
|
// If current file has no ID, get the most recent file
|
||||||
.then(ifNoId(() => store.getters['files/mostRecent']))
|
.then(ifNoId(() => store.getters['files/mostRecent']))
|
||||||
// If still no ID, create a new file
|
// If still no ID, create a new file
|
||||||
.then(ifNoId(() => {
|
.then(ifNoId(() => {
|
||||||
const contentId = utils.uid();
|
const contentId = utils.uid();
|
||||||
store.commit('contents/setItem', {
|
store.commit('contents/setItem', {
|
||||||
id: contentId,
|
id: contentId,
|
||||||
text: welcomeFile,
|
text: welcomeFile,
|
||||||
});
|
});
|
||||||
const fileId = utils.uid();
|
const fileId = utils.uid();
|
||||||
store.commit('files/setItem', {
|
store.commit('files/setItem', {
|
||||||
id: fileId,
|
id: fileId,
|
||||||
name: 'Welcome file',
|
name: 'Welcome file',
|
||||||
contentId,
|
contentId,
|
||||||
});
|
});
|
||||||
return store.state.files.itemMap[fileId];
|
return store.state.files.itemMap[fileId];
|
||||||
}))
|
}))
|
||||||
.then((currentFile) => {
|
.then((currentFile) => {
|
||||||
store.commit('files/setCurrentId', currentFile.id);
|
store.commit('files/setCurrentId', currentFile.id);
|
||||||
store.dispatch('files/patchCurrent', {}); // Update `updated` field to make it the mostRecent
|
store.dispatch('files/patchCurrent', {}); // Update `updated` field to make it the mostRecent
|
||||||
}),
|
}), {
|
||||||
{
|
|
||||||
immediate: true,
|
immediate: true,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
utils.setInterval(() => localDbSvc.sync(), 1200);
|
// Sync local DB periodically
|
||||||
|
utils.setInterval(() => localDbSvc.sync(), 1000);
|
||||||
|
|
||||||
|
export default {
|
||||||
|
isSyncAvailable,
|
||||||
|
requestSync,
|
||||||
|
};
|
||||||
|
28
src/services/userActivitySvc.js
Normal file
28
src/services/userActivitySvc.js
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
const inactiveAfter = 2 * 60 * 1000; // 2 minutes
|
||||||
|
let lastActivity;
|
||||||
|
let lastFocus;
|
||||||
|
const lastFocusKey = 'lastWindowFocus';
|
||||||
|
|
||||||
|
function setLastActivity() {
|
||||||
|
lastActivity = Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setLastFocus() {
|
||||||
|
lastFocus = Date.now();
|
||||||
|
localStorage[lastFocusKey] = lastFocus;
|
||||||
|
setLastActivity();
|
||||||
|
}
|
||||||
|
|
||||||
|
setLastFocus();
|
||||||
|
window.addEventListener('focus', setLastFocus);
|
||||||
|
window.document.addEventListener('mousedown', setLastActivity);
|
||||||
|
window.document.addEventListener('keydown', setLastActivity);
|
||||||
|
|
||||||
|
export default {
|
||||||
|
isFocused() {
|
||||||
|
return parseInt(localStorage[lastFocusKey], 10) === lastFocus;
|
||||||
|
},
|
||||||
|
isActive() {
|
||||||
|
return lastActivity > Date.now() - inactiveAfter && this.isFocused();
|
||||||
|
},
|
||||||
|
};
|
@ -1,70 +0,0 @@
|
|||||||
import utils from './utils';
|
|
||||||
import store from '../store';
|
|
||||||
|
|
||||||
const googleClientId = '241271498917-t4t7d07qis7oc0ahaskbif3ft6tk63cd.apps.googleusercontent.com';
|
|
||||||
const appUri = 'http://localhost:8080/';
|
|
||||||
const googleAppsDomain = null;
|
|
||||||
const origin = `${location.protocol}//${location.host}`;
|
|
||||||
|
|
||||||
export default {
|
|
||||||
oauth2Context: null,
|
|
||||||
signinWithGoogle() {
|
|
||||||
this.cleanOauth2Context();
|
|
||||||
const state = utils.uid();
|
|
||||||
let authorizeUrl = 'https://accounts.google.com/o/oauth2/v2/auth';
|
|
||||||
authorizeUrl = utils.addQueryParam(authorizeUrl, 'client_id', googleClientId);
|
|
||||||
authorizeUrl = utils.addQueryParam(authorizeUrl, 'response_type', 'token');
|
|
||||||
authorizeUrl = utils.addQueryParam(authorizeUrl, 'redirect_uri', `${appUri}oauth2/callback.html`);
|
|
||||||
authorizeUrl = utils.addQueryParam(authorizeUrl, 'state', state);
|
|
||||||
if (googleAppsDomain) {
|
|
||||||
authorizeUrl = utils.addQueryParam(authorizeUrl, 'scope', 'openid email');
|
|
||||||
authorizeUrl = utils.addQueryParam(authorizeUrl, 'hd', googleAppsDomain);
|
|
||||||
} else {
|
|
||||||
authorizeUrl = utils.addQueryParam(authorizeUrl, 'scope', 'profile email');
|
|
||||||
}
|
|
||||||
const wnd = window.open(authorizeUrl);
|
|
||||||
if (!wnd) {
|
|
||||||
return Promise.resolve();
|
|
||||||
}
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
const msgHandler = (event) => {
|
|
||||||
if (event.source === wnd
|
|
||||||
&& event.origin === origin
|
|
||||||
&& event.data
|
|
||||||
&& event.data.state === state
|
|
||||||
) {
|
|
||||||
this.cleanOauth2Context();
|
|
||||||
if (event.data.accessToken) {
|
|
||||||
store.dispatch('data/patchTokens', {
|
|
||||||
googleToken: {
|
|
||||||
accessToken: event.data.accessToken,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
resolve();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
window.addEventListener('message', msgHandler);
|
|
||||||
const checkClosedInterval = setInterval(() => {
|
|
||||||
if (this.oauth2Context && this.oauth2Context.wnd.closed) {
|
|
||||||
this.cleanOauth2Context();
|
|
||||||
}
|
|
||||||
}, 200);
|
|
||||||
this.oauth2Context = {
|
|
||||||
wnd,
|
|
||||||
msgHandler,
|
|
||||||
checkClosedInterval,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
},
|
|
||||||
cleanOauth2Context() {
|
|
||||||
if (this.oauth2Context) {
|
|
||||||
clearInterval(this.oauth2Context.checkClosedInterval);
|
|
||||||
if (!this.oauth2Context.wnd.closed) {
|
|
||||||
this.oauth2Context.wnd.close();
|
|
||||||
}
|
|
||||||
window.removeEventListener('message', this.oauth2Context.msgHandler);
|
|
||||||
this.oauth2Context = null;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
@ -1,20 +1,29 @@
|
|||||||
|
// For uid()
|
||||||
const crypto = window.crypto || window.msCrypto;
|
const crypto = window.crypto || window.msCrypto;
|
||||||
const alphabet = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('');
|
const alphabet = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('');
|
||||||
const radix = alphabet.length;
|
const radix = alphabet.length;
|
||||||
const array = new Uint32Array(20);
|
const array = new Uint32Array(20);
|
||||||
|
|
||||||
|
// For addQueryParam()
|
||||||
const urlParser = window.document.createElement('a');
|
const urlParser = window.document.createElement('a');
|
||||||
|
|
||||||
|
// For loadScript()
|
||||||
|
const scriptLoadingPromises = Object.create(null);
|
||||||
|
|
||||||
|
// For startOauth2()
|
||||||
|
const origin = `${location.protocol}//${location.host}`;
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
uid() {
|
uid() {
|
||||||
crypto.getRandomValues(array);
|
crypto.getRandomValues(array);
|
||||||
return array.cl_map(value => alphabet[value % radix]).join('');
|
return array.cl_map(value => alphabet[value % radix]).join('');
|
||||||
},
|
},
|
||||||
setInterval(func, interval) {
|
setInterval(func, interval) {
|
||||||
const randomizedInterval = Math.floor((1 + ((Math.random() - 0.5) * 0.1)) * interval);
|
const randomizedInterval = Math.floor((1 + (Math.random() * 0.1)) * interval);
|
||||||
setInterval(() => func(), randomizedInterval);
|
return setInterval(() => func(), randomizedInterval);
|
||||||
},
|
},
|
||||||
addQueryParam(url, key, value) {
|
addQueryParam(url, key, value) {
|
||||||
if (!url || !key || !value) {
|
if (!url || !key || value == null) {
|
||||||
return url;
|
return url;
|
||||||
}
|
}
|
||||||
urlParser.href = url;
|
urlParser.href = url;
|
||||||
@ -26,4 +35,191 @@ export default {
|
|||||||
urlParser.search += `${encodeURIComponent(key)}=${encodeURIComponent(value)}`;
|
urlParser.search += `${encodeURIComponent(key)}=${encodeURIComponent(value)}`;
|
||||||
return urlParser.href;
|
return urlParser.href;
|
||||||
},
|
},
|
||||||
|
loadScript(url) {
|
||||||
|
if (!scriptLoadingPromises[url]) {
|
||||||
|
scriptLoadingPromises[url] = new Promise((resolve, reject) => {
|
||||||
|
const script = document.createElement('script');
|
||||||
|
script.onload = resolve;
|
||||||
|
script.onerror = () => {
|
||||||
|
scriptLoadingPromises[url] = null;
|
||||||
|
reject();
|
||||||
|
};
|
||||||
|
script.src = url;
|
||||||
|
document.head.appendChild(script);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return scriptLoadingPromises[url];
|
||||||
|
},
|
||||||
|
startOauth2(url, params = {}, silent = false) {
|
||||||
|
const oauth2Context = {};
|
||||||
|
|
||||||
|
// Build the authorize URL
|
||||||
|
const state = this.uid();
|
||||||
|
params.state = state;
|
||||||
|
params.redirect_uri = `${origin}/oauth2/callback.html`;
|
||||||
|
let authorizeUrl = url;
|
||||||
|
Object.keys(params).forEach((key) => {
|
||||||
|
authorizeUrl = this.addQueryParam(authorizeUrl, key, params[key]);
|
||||||
|
});
|
||||||
|
if (silent) {
|
||||||
|
// Use an iframe as wnd for silent mode
|
||||||
|
oauth2Context.iframeElt = document.createElement('iframe');
|
||||||
|
oauth2Context.iframeElt.style.position = 'absolute';
|
||||||
|
oauth2Context.iframeElt.style.left = '-9999px';
|
||||||
|
oauth2Context.iframeElt.onload = () => {
|
||||||
|
oauth2Context.closeTimeout = setTimeout(() => oauth2Context.clean(), 5 * 1000);
|
||||||
|
};
|
||||||
|
oauth2Context.iframeElt.src = authorizeUrl;
|
||||||
|
document.body.appendChild(oauth2Context.iframeElt);
|
||||||
|
oauth2Context.wnd = oauth2Context.iframeElt.contentWindow;
|
||||||
|
} else {
|
||||||
|
// Open a new tab otherwise
|
||||||
|
oauth2Context.wnd = window.open(authorizeUrl);
|
||||||
|
// If window opening has been blocked by the browser
|
||||||
|
if (!oauth2Context.wnd) {
|
||||||
|
return Promise.reject();
|
||||||
|
}
|
||||||
|
oauth2Context.closeTimeout = setTimeout(() => oauth2Context.clean(), 120 * 1000);
|
||||||
|
}
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
oauth2Context.clean = (errorMsg) => {
|
||||||
|
clearInterval(oauth2Context.checkClosedInterval);
|
||||||
|
if (!silent && !oauth2Context.wnd.closed) {
|
||||||
|
oauth2Context.wnd.close();
|
||||||
|
}
|
||||||
|
if (oauth2Context.iframeElt) {
|
||||||
|
document.body.removeChild(oauth2Context.iframeElt);
|
||||||
|
}
|
||||||
|
clearTimeout(oauth2Context.closeTimeout);
|
||||||
|
window.removeEventListener('message', oauth2Context.msgHandler);
|
||||||
|
oauth2Context.clean = () => {}; // Prevent from cleaning several times
|
||||||
|
if (errorMsg) {
|
||||||
|
reject(new Error(errorMsg));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
oauth2Context.msgHandler = (event) => {
|
||||||
|
if (event.source === oauth2Context.wnd &&
|
||||||
|
event.origin === origin &&
|
||||||
|
event.data &&
|
||||||
|
event.data.state === state
|
||||||
|
) {
|
||||||
|
oauth2Context.clean();
|
||||||
|
if (event.data.accessToken) {
|
||||||
|
resolve(event.data);
|
||||||
|
} else {
|
||||||
|
reject(event.data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('message', oauth2Context.msgHandler);
|
||||||
|
|
||||||
|
oauth2Context.checkClosedInterval = !silent && setInterval(() => {
|
||||||
|
if (oauth2Context.wnd.closed) {
|
||||||
|
oauth2Context.clean();
|
||||||
|
}
|
||||||
|
}, 200);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
request(configParam) {
|
||||||
|
const timeout = 30 * 1000; // 30 sec
|
||||||
|
let retryAfter = 500; // 500 ms
|
||||||
|
const maxRetryAfter = 30 * 1000; // 30 sec
|
||||||
|
const config = Object.assign({}, configParam);
|
||||||
|
config.headers = Object.assign({
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
}, config.headers);
|
||||||
|
|
||||||
|
function parseHeaders(xhr) {
|
||||||
|
const pairs = xhr.getAllResponseHeaders().trim().split('\n');
|
||||||
|
return pairs.reduce((headers, header) => {
|
||||||
|
const split = header.trim().split(':');
|
||||||
|
const key = split.shift().trim().toLowerCase();
|
||||||
|
const value = split.join(':').trim();
|
||||||
|
headers[key] = value;
|
||||||
|
return headers;
|
||||||
|
}, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRetriable(err) {
|
||||||
|
switch (err.status) {
|
||||||
|
case 403:
|
||||||
|
{
|
||||||
|
const googleReason = ((((err.body || {}).error || {}).errors || [])[0] || {}).reason;
|
||||||
|
return googleReason === 'rateLimitExceeded' || googleReason === 'userRateLimitExceeded';
|
||||||
|
}
|
||||||
|
case 429:
|
||||||
|
return true;
|
||||||
|
default:
|
||||||
|
if (err.status >= 500 && err.status < 600) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const attempt =
|
||||||
|
() => new Promise((resolve, reject) => {
|
||||||
|
const xhr = new window.XMLHttpRequest();
|
||||||
|
let timeoutId;
|
||||||
|
|
||||||
|
xhr.onload = () => {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
const result = {
|
||||||
|
status: xhr.status,
|
||||||
|
headers: parseHeaders(xhr),
|
||||||
|
body: xhr.responseText,
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
result.body = JSON.parse(result.body);
|
||||||
|
} catch (e) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
if (result.status >= 200 && result.status < 300) {
|
||||||
|
resolve(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
reject(result);
|
||||||
|
};
|
||||||
|
|
||||||
|
xhr.onerror = () => {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
reject(new Error('Network request failed.'));
|
||||||
|
};
|
||||||
|
|
||||||
|
timeoutId = setTimeout(() => {
|
||||||
|
xhr.abort();
|
||||||
|
reject(new Error('Network request timeout.'));
|
||||||
|
}, timeout);
|
||||||
|
|
||||||
|
// Add query params to URL
|
||||||
|
let url = config.url || '';
|
||||||
|
if (config.params) {
|
||||||
|
Object.keys(config.params).forEach((key) => {
|
||||||
|
url = this.addQueryParam(url, key, config.params[key]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
xhr.open(config.method, url);
|
||||||
|
Object.keys(config.headers).forEach((key) => {
|
||||||
|
xhr.setRequestHeader(key, config.headers[key]);
|
||||||
|
});
|
||||||
|
xhr.send(config.body ? JSON.stringify(config.body) : null);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
// Try again later in case of retriable error
|
||||||
|
if (isRetriable(err) && retryAfter < maxRetryAfter) {
|
||||||
|
return new Promise(
|
||||||
|
(resolve) => {
|
||||||
|
setTimeout(resolve, retryAfter);
|
||||||
|
// Exponential backoff
|
||||||
|
retryAfter *= 2;
|
||||||
|
})
|
||||||
|
.then(attempt);
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
});
|
||||||
|
|
||||||
|
return attempt();
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
@ -9,6 +9,7 @@ import layout from './modules/layout';
|
|||||||
import editor from './modules/editor';
|
import editor from './modules/editor';
|
||||||
import explorer from './modules/explorer';
|
import explorer from './modules/explorer';
|
||||||
import modal from './modules/modal';
|
import modal from './modules/modal';
|
||||||
|
import queue from './modules/queue';
|
||||||
|
|
||||||
Vue.use(Vuex);
|
Vue.use(Vuex);
|
||||||
|
|
||||||
@ -32,6 +33,7 @@ const store = new Vuex.Store({
|
|||||||
editor,
|
editor,
|
||||||
explorer,
|
explorer,
|
||||||
modal,
|
modal,
|
||||||
|
queue,
|
||||||
},
|
},
|
||||||
strict: debug,
|
strict: debug,
|
||||||
plugins: debug ? [createLogger()] : [],
|
plugins: debug ? [createLogger()] : [],
|
||||||
|
@ -13,30 +13,42 @@ const module = moduleTemplate(empty);
|
|||||||
|
|
||||||
const getter = id => state => state.itemMap[id] || empty(id);
|
const getter = id => state => state.itemMap[id] || empty(id);
|
||||||
|
|
||||||
const localSettingsToggler = propertyName => ({ getters, dispatch }, value) => {
|
|
||||||
dispatch('patchLocalSettings', {
|
|
||||||
[propertyName]: value === undefined ? !getters.localSettings[propertyName] : value,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
module.getters = {
|
module.getters = {
|
||||||
...module.getters,
|
...module.getters,
|
||||||
localSettings: getter('localSettings'),
|
localSettings: getter('localSettings'),
|
||||||
|
settings: getter('settings'),
|
||||||
|
syncData: getter('syncData'),
|
||||||
tokens: getter('tokens'),
|
tokens: getter('tokens'),
|
||||||
|
googleTokens: (state, getters) => getters.tokens.google || {},
|
||||||
|
loginToken: (state, getters) => {
|
||||||
|
const googleTokens = getters.googleTokens;
|
||||||
|
// Return the first googleToken that has the isLogin flag
|
||||||
|
const loginSubs = Object.keys(googleTokens)
|
||||||
|
.filter(sub => googleTokens[sub].isLogin);
|
||||||
|
return googleTokens[loginSubs[0]];
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const patcher = id => ({ getters, commit }, value) => commit('patchOrSetItem', {
|
||||||
|
...value,
|
||||||
|
id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const localSettingsToggler = propertyName => ({ getters, dispatch }, value) => dispatch('patchLocalSettings', {
|
||||||
|
[propertyName]: value === undefined ? !getters.localSettings[propertyName] : value,
|
||||||
|
});
|
||||||
|
|
||||||
module.actions = {
|
module.actions = {
|
||||||
...module.actions,
|
...module.actions,
|
||||||
patchLocalSettings({ getters, commit }, value) {
|
patchLocalSettings: patcher('localSettings'),
|
||||||
commit('patchOrSetItem', {
|
patchSyncData: patcher('syncData'),
|
||||||
...value,
|
patchTokens: patcher('tokens'),
|
||||||
id: 'localSettings',
|
setGoogleToken({ getters, dispatch }, googleToken) {
|
||||||
});
|
dispatch('patchTokens', {
|
||||||
},
|
google: {
|
||||||
patchTokens({ getters, commit }, value) {
|
...getters.googleTokens,
|
||||||
commit('patchOrSetItem', {
|
[googleToken.sub]: googleToken,
|
||||||
...value,
|
},
|
||||||
id: 'tokens',
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
toggleNavigationBar: localSettingsToggler('showNavigationBar'),
|
toggleNavigationBar: localSettingsToggler('showNavigationBar'),
|
||||||
|
42
src/store/modules/queue.js
Normal file
42
src/store/modules/queue.js
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
const setter = propertyName => (state, value) => {
|
||||||
|
state[propertyName] = value;
|
||||||
|
};
|
||||||
|
|
||||||
|
let queue = Promise.resolve();
|
||||||
|
|
||||||
|
export default {
|
||||||
|
namespaced: true,
|
||||||
|
state: {
|
||||||
|
isEmpty: true,
|
||||||
|
isSyncRequested: false,
|
||||||
|
},
|
||||||
|
mutations: {
|
||||||
|
setIsEmpty: setter('isEmpty'),
|
||||||
|
setIsSyncRequested: setter('isSyncRequested'),
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
enqueue({ state, commit }, cb) {
|
||||||
|
if (state.isEmpty) {
|
||||||
|
commit('setIsEmpty', false);
|
||||||
|
}
|
||||||
|
const newQueue = queue
|
||||||
|
.then(cb)
|
||||||
|
.catch((err) => {
|
||||||
|
console.error(err);
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
if (newQueue === queue) {
|
||||||
|
commit('setIsEmpty', true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
queue = newQueue;
|
||||||
|
},
|
||||||
|
enqueueSyncRequest({ state, commit, dispatch }, cb) {
|
||||||
|
if (!state.isSyncRequested) {
|
||||||
|
commit('setIsSyncRequested', true);
|
||||||
|
const unset = () => commit('setIsSyncRequested', false);
|
||||||
|
dispatch('enqueue', () => cb().then(unset, unset));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
@ -4,6 +4,7 @@
|
|||||||
<script>
|
<script>
|
||||||
var state;
|
var state;
|
||||||
var accessToken;
|
var accessToken;
|
||||||
|
var expiresIn;
|
||||||
function parse(search) {
|
function parse(search) {
|
||||||
(search || '').slice(1).split('&').forEach(function (param) {
|
(search || '').slice(1).split('&').forEach(function (param) {
|
||||||
var split = param.split('=');
|
var split = param.split('=');
|
||||||
@ -13,15 +14,18 @@
|
|||||||
state = value;
|
state = value;
|
||||||
} else if (key === 'access_token') {
|
} else if (key === 'access_token') {
|
||||||
accessToken = value;
|
accessToken = value;
|
||||||
|
} else if (key === 'expires_in') {
|
||||||
|
expiresIn = value;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
parse(location.search);
|
parse(location.search);
|
||||||
parse(location.hash);
|
parse(location.hash);
|
||||||
var origin = location.protocol + '//' + location.host;
|
var origin = location.protocol + '//' + location.host;
|
||||||
opener.postMessage({
|
(window.opener || window.parent).postMessage({
|
||||||
state: state,
|
state: state,
|
||||||
accessToken: accessToken
|
accessToken: accessToken,
|
||||||
|
expiresIn: expiresIn
|
||||||
}, origin);
|
}, origin);
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
Loading…
Reference in New Issue
Block a user