├── static └── .gitkeep ├── src ├── components │ ├── HelloWorld.vue │ └── UsersTable.vue ├── assets │ ├── logo.png │ └── gas.svg ├── store │ ├── modules │ │ ├── template.js │ │ ├── users.js │ │ └── auth.js │ └── index.js ├── gas.config.js ├── main.js ├── views │ ├── Users.vue │ ├── Signin.vue │ └── Home.vue ├── App.vue ├── utils.js └── router │ └── index.js ├── config ├── prod.env.js ├── dev.env.js └── index.js ├── .clasp.json ├── .editorconfig ├── .gitignore ├── gas ├── appsscript.json ├── index.html ├── main.js └── api.js ├── .babelrc ├── .postcssrc.js ├── index.html ├── LICENSE ├── README.md ├── gas.js └── package.json /static/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/HelloWorld.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /config/prod.env.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | module.exports = { 3 | NODE_ENV: '"production"' 4 | } 5 | -------------------------------------------------------------------------------- /src/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ashtonfei/vuejs-gas-template/HEAD/src/assets/logo.png -------------------------------------------------------------------------------- /.clasp.json: -------------------------------------------------------------------------------- 1 | {"scriptId":"13Oy9WzAb8YHjUMph4pP78ATnRx-IbBdBzaPboRV-kte90Q0kxBjgwtpx","rootDir":"./gas"} 2 | -------------------------------------------------------------------------------- /config/dev.env.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const merge = require('webpack-merge') 3 | const prodEnv = require('./prod.env') 4 | 5 | module.exports = merge(prodEnv, { 6 | NODE_ENV: '"development"' 7 | }) 8 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_style = space 6 | indent_size = 4 7 | end_of_line = lf 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /gas/vue/ 2 | /logs 3 | 4 | .DS_Store 5 | node_modules/ 6 | /dist/ 7 | npm-debug.log* 8 | yarn-debug.log* 9 | yarn-error.log* 10 | 11 | # Editor directories and files 12 | .idea 13 | .vscode 14 | *.suo 15 | *.ntvs* 16 | *.njsproj 17 | *.sln 18 | -------------------------------------------------------------------------------- /gas/appsscript.json: -------------------------------------------------------------------------------- 1 | { 2 | "timeZone": "America/New_York", 3 | "dependencies": {}, 4 | "exceptionLogging": "STACKDRIVER", 5 | "runtimeVersion": "V8", 6 | "webapp": { 7 | "executeAs": "USER_DEPLOYING", 8 | "access": "ANYONE_ANONYMOUS" 9 | } 10 | } -------------------------------------------------------------------------------- /src/store/modules/template.js: -------------------------------------------------------------------------------- 1 | const state = () => ({ 2 | }) 3 | 4 | const getters = {} 5 | const actions = {} 6 | const mutations = {} 7 | 8 | export default { 9 | namespaced: true, 10 | state, 11 | getters, 12 | actions, 13 | mutations, 14 | } -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | ["env", { 4 | "modules": false, 5 | "targets": { 6 | "browsers": ["> 1%", "last 2 versions", "not ie <= 8"] 7 | } 8 | }], 9 | "stage-2" 10 | ], 11 | "plugins": ["transform-vue-jsx", "transform-runtime"] 12 | } 13 | -------------------------------------------------------------------------------- /.postcssrc.js: -------------------------------------------------------------------------------- 1 | // https://github.com/michael-ciniawsky/postcss-load-config 2 | 3 | module.exports = { 4 | "plugins": { 5 | "postcss-import": {}, 6 | "postcss-url": {}, 7 | // to edit target browsers: use "browserslist" field in package.json 8 | "autoprefixer": {} 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/store/index.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import Vuex from "vuex" 3 | import auth from './modules/auth' 4 | import users from './modules/users' 5 | 6 | Vue.use(Vuex) 7 | 8 | const store = new Vuex.Store({ 9 | modules: { 10 | auth, 11 | users, 12 | }, 13 | }) 14 | 15 | export default store -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | vuejs-gas-template 7 | 8 | 9 |
10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /src/gas.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | debug: true, 3 | googleNotDefined: 'google is not defined', 4 | authTokenKey: "gas_jwt_token", 5 | testToken: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoiQXNodG9uIEZlaSJ9.7TIf2tNTvbGTih25no2_9Q--4zf0lTPIEGxJzNcypXU=", 6 | testUser: { 7 | id: 'test', 8 | name: "Ashton Fei", 9 | role: 'admin', 10 | email: 'test@gamil.com', 11 | token: this.testToken, 12 | } 13 | } -------------------------------------------------------------------------------- /gas/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | vuejs-gas-template 9 | 10 | 11 | 12 | 13 |
14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /src/main.js: -------------------------------------------------------------------------------- 1 | // The Vue build version to load with the `import` command 2 | // (runtime-only or standalone) has been set in webpack.base.conf with an alias. 3 | import Vue from 'vue' 4 | import App from './App' 5 | import router from './router' 6 | import store from './store/index' 7 | 8 | Vue.config.productionTip = false 9 | 10 | /* eslint-disable no-new */ 11 | new Vue({ 12 | el: '#app', 13 | router, 14 | store, 15 | components: { App }, 16 | template: '' 17 | }) 18 | -------------------------------------------------------------------------------- /src/components/UsersTable.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 34 | 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Ashton Fei 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # vuejs-gas-template 2 | 3 | > A vuejs template for building web application with google apps script 4 | 5 | ## Web App 6 | 7 | [vuejs-gas-template-app](https://script.google.com/macros/s/AKfycbwCJGuZb5gAntCPlQYAg9TJOXOL8ZLQ3_af-LQs9JyBxEueflVwCOaoQid9wGQsyE47TQ/exec) 8 | 9 | ## Build Setup 10 | 11 | ```bash 12 | # install dependencies 13 | npm install 14 | 15 | # serve with hot reload at localhost:8080 16 | npm run dev 17 | 18 | # build for production with minification 19 | npm run build 20 | 21 | # build for production and view the bundle analyzer report 22 | npm run build --report 23 | 24 | # build for web application with apps script 25 | npm run build-gas --build apps script 26 | 27 | # create a new Google Apps Script project 28 | clasp create --title 'project-name' --type standalone --rootDir ./gas 29 | 30 | # push scripts to the new project 31 | clasp push 32 | ``` 33 | 34 | ## Screenshots 35 | 36 | ![image](https://user-images.githubusercontent.com/16481229/122939470-3a3c6300-d3a6-11eb-86e5-49a07bac96f6.png) 37 | ![image](https://user-images.githubusercontent.com/16481229/122939681-6ce65b80-d3a6-11eb-9e26-f2ac48a97789.png) 38 | ![image](https://user-images.githubusercontent.com/16481229/122939824-88e9fd00-d3a6-11eb-8f83-e586c6de21ad.png) 39 | 40 | For a detailed explanation on how things work, check out the [guide](http://vuejs-templates.github.io/webpack/) and [docs for vue-loader](http://vuejs.github.io/vue-loader). 41 | -------------------------------------------------------------------------------- /src/views/Users.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 72 | 73 | -------------------------------------------------------------------------------- /src/App.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 42 | 43 | 83 | -------------------------------------------------------------------------------- /gas.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | const path = require('path') 3 | 4 | const GAS_VUE_PATH = path.join(__dirname, 'gas', 'vue') 5 | const JS_PATH = path.join(__dirname, 'dist', 'static', 'js') 6 | const CSS_PATH = path.join(__dirname, 'dist', 'static', 'css') 7 | const TYPE_JS = ".js" 8 | const TYPE_CSS = ".css" 9 | 10 | function createGasFile(filePath, newFilePath, type) { 11 | let data = fs.readFileSync(filePath, 'utf-8') 12 | if (type === TYPE_JS) { 13 | data = `` 14 | } else { 15 | data = `` 16 | } 17 | fs.writeFileSync(newFilePath, data) 18 | console.log(`[${new Date().toLocaleTimeString()}] ${newFilePath.split(path.sep).pop()} has been created.`) 19 | } 20 | 21 | try { 22 | if (!fs.existsSync(GAS_VUE_PATH)) fs.mkdirSync(GAS_VUE_PATH) 23 | console.log(`[${new Date().toLocaleTimeString()}] Creating JS Files for GAS ...`) 24 | const jsFiles = fs.readdirSync(JS_PATH) 25 | jsFiles.forEach(fileName => { 26 | if (fileName.endsWith(TYPE_JS)) { 27 | filePath = path.join(JS_PATH, fileName) 28 | const newFilePath = path.join(GAS_VUE_PATH, fileName.split('.')[0] + TYPE_JS + '.html') 29 | createGasFile(filePath, newFilePath, TYPE_JS) 30 | } 31 | }) 32 | 33 | console.log(`[${new Date().toLocaleTimeString()}] Creating CSS Files for GAS ...`) 34 | const cssFiles = fs.readdirSync(CSS_PATH) 35 | cssFiles.forEach(fileName => { 36 | if (fileName.endsWith(TYPE_CSS)) { 37 | filePath = path.join(CSS_PATH, fileName) 38 | const newFilePath = path.join(GAS_VUE_PATH, fileName.split('.')[0] + TYPE_CSS + '.html') 39 | createGasFile(filePath, newFilePath, TYPE_CSS) 40 | } 41 | }) 42 | 43 | console.info(`[${new Date().toLocaleTimeString()}] Done!`) 44 | } catch (error) { 45 | console.error(`[${new Date().toLocaleTimeString()}] Error: ${error.message}`) 46 | } 47 | -------------------------------------------------------------------------------- /src/assets/gas.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/store/modules/users.js: -------------------------------------------------------------------------------- 1 | import { GOOGLE_NOT_DEFINED, getToken } from '@/utils' 2 | 3 | const state = () => ({ 4 | users: [], 5 | defaultUsers: [ 6 | { 7 | id: 1, 8 | name: "Ashton 1", 9 | gender: "Male", 10 | email: "yunjia.fei@gmail.com", 11 | role: "admin", 12 | status: "active", 13 | }, 14 | { 15 | id: 2, 16 | name: "Ashton 2", 17 | gender: "Male", 18 | email: "yunjia.fei@gmail.com", 19 | role: "staff", 20 | status: "active", 21 | }, 22 | { 23 | id: 3, 24 | name: "Ashton 3", 25 | gender: "Female", 26 | email: "yunjia.fei@gmail.com", 27 | role: "manager", 28 | status: "inactive", 29 | }, 30 | ], 31 | }) 32 | 33 | const getters = { 34 | activeUsers: state => state.users.filter(v => v.status.toLowerCase() === 'active'), 35 | activeUsersCount: (getters) => getters.activeUsers.length, 36 | } 37 | 38 | const actions = { 39 | getAllUsers: ({ commit, state }) => { 40 | const token = getToken() 41 | if (token === null || token === "null") return 42 | try { 43 | google.script.run 44 | .withSuccessHandler(response => { 45 | const { success, message, data } = JSON.parse(response) 46 | if (!success) alert(message) 47 | commit('setUsers', data) 48 | }) 49 | .withFailureHandler(err => { 50 | alert(err.message) 51 | }) 52 | .request("GET", "users", '{}', token) 53 | } catch (err) { 54 | commit('setUsers', state.defaultUsers) 55 | } 56 | } 57 | } 58 | 59 | const mutations = { 60 | setUsers: (state, data) => state.users = [...data], 61 | } 62 | 63 | export default { 64 | namespaced: true, 65 | state, 66 | getters, 67 | actions, 68 | mutations, 69 | } -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | const GOOGLE_NOT_DEFINED = 'google is not defined' 2 | const AUTH_TOKEN_KEY = "gas_jwt_token" 3 | const TEST_TOKEN = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoiQXNodG9uIEZlaSJ9.7TIf2tNTvbGTih25no2_9Q--4zf0lTPIEGxJzNcypXU=" 4 | const TEST_USER = { 5 | id: 'test', 6 | name: "Ashton Fei", 7 | role: 'admin', 8 | email: 'test@gamil.com', 9 | token: TEST_TOKEN, 10 | } 11 | 12 | const getLocalItem = (key) => { 13 | try { 14 | let token = localStorage.getItem(key) 15 | return token == 'null' ? null : token 16 | } catch (err) { 17 | return null 18 | } 19 | } 20 | 21 | const setLocalItem = (key, value) => { 22 | try { 23 | return localStorage.setItem(key, value) 24 | } catch (err) { 25 | //pass 26 | } 27 | } 28 | 29 | const removeLocalItem = (key) => { 30 | try { 31 | localStorage.removeItem(key) 32 | } catch (err) { 33 | //pass 34 | } 35 | } 36 | 37 | /** 38 | * 39 | * @returns get auth token from local storage 40 | */ 41 | const getToken = () => getLocalItem(AUTH_TOKEN_KEY) 42 | /** 43 | * 44 | * @param {string} token the token provided by the server side 45 | */ 46 | const setToken = (token) => setLocalItem(AUTH_TOKEN_KEY, token) 47 | const removeToken = () => removeLocalItem(AUTH_TOKEN_KEY) 48 | 49 | 50 | /** 51 | * 52 | * @param {string} functionName the function name google.script.run.{functionName} which is created in your apps script project 53 | * @param {object} params an object of parameters 54 | */ 55 | const runApi = (functionName, params) => { 56 | if (typeof params === 'object') params = JSON.stringify(params) 57 | return new Promise((resolve, reject) => { 58 | google.script.run 59 | .withSuccessHandler(reply => resolve(JSON.parse(reply))) 60 | .withFailureHandler(err => reject({ success: false, message: err.message }))[functionName](params) 61 | }) 62 | } 63 | 64 | 65 | export { 66 | GOOGLE_NOT_DEFINED, 67 | AUTH_TOKEN_KEY, 68 | TEST_USER, 69 | TEST_TOKEN, 70 | getLocalItem, 71 | setLocalItem, 72 | removeLocalItem, 73 | getToken, 74 | setToken, 75 | removeToken, 76 | runApi 77 | } 78 | -------------------------------------------------------------------------------- /gas/main.js: -------------------------------------------------------------------------------- 1 | const SETTINGS = { 2 | ROOT_URL: "https://script.google.com/macros/s/AKfycbzJX6xTOZSu_hIywmZ1_LBxouEAgUUOuNnI9DH3KV1l/dev", 3 | SECRECT: "asdflkja;lskjdfi12;lkjafjslkdf", 4 | APP_NAME: "VueJS GAS Template", 5 | SSDB_ID: "15zF38SRtW9LjFFrwHYN9-VFROPH6_cn25lqrhlHtxpg", 6 | AUTH_TABLE_NAME: 'users', 7 | SESSION_EXPRATION_IN_SECONDS: 6 * 60 * 60, // Max 6 hours, min 1s 8 | } 9 | 10 | function include(filename) { 11 | return HtmlService.createTemplateFromFile(filename).evaluate().getContent() 12 | } 13 | 14 | function getUrl() { 15 | return ScriptApp.getService().getUrl() 16 | } 17 | 18 | function doGet(e) { 19 | const htmlOuput = HtmlService.createTemplateFromFile("index.html").evaluate() 20 | htmlOuput.setTitle(SETTINGS.APP_NAME) 21 | .setXFrameOptionsMode(HtmlService.XFrameOptionsMode.ALLOWALL) 22 | .addMetaTag("viewport", "width=device-width,initial-scale=1") 23 | return htmlOuput 24 | } 25 | 26 | const request = (method, tableName, stringify_json, token) => { 27 | let response = { success: true, message: "Your request has been done successfully.", token } 28 | if (!JWT.isValidToken(token)) return { success: false, message: "Your session is not valid anymore, please sign in again!", token: null } 29 | const data = JSON.parse(stringify_json) 30 | if (method.toUpperCase() === "GET") response = API.get(tableName, data) 31 | if (method.toUpperCase() === "POST") response = API.post(tableName, data) 32 | if (method.toUpperCase() === "DELETE") response = API.delete(tableName, data) 33 | return JSON.stringify({ ...response, token }) 34 | } 35 | 36 | const validateToken = (token) => JSON.stringify(Auth.validateToken(token)) 37 | 38 | const signout = (token) => JSON.stringify(Auth.signout(token)) 39 | 40 | const signin = (data) => { 41 | const { email, password } = JSON.parse(data) 42 | return JSON.stringify(Auth.singin(email, password)) 43 | } 44 | 45 | const createHashPassword = () => { 46 | const password = '123456' 47 | console.log(Auth.hashPassword(password)) 48 | } 49 | 50 | const test = () => { 51 | const users = API.get(SETTINGS.AUTH_TABLE_NAME, { role: 'admin' }) 52 | console.log(users) 53 | } 54 | 55 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vuejs-gas-template", 3 | "version": "1.0.0", 4 | "description": "A vuejs template for building web application with google apps script", 5 | "author": "Ashton Fei ", 6 | "private": true, 7 | "scripts": { 8 | "dev": "webpack-dev-server --inline --progress --config build/webpack.dev.conf.js", 9 | "start": "npm run dev", 10 | "build": "node build/build.js", 11 | "build-gas": "npm run build && node gas.js" 12 | }, 13 | "dependencies": { 14 | "vue": "^2.5.2", 15 | "vue-router": "^3.0.1", 16 | "vuex": "^3.6.2" 17 | }, 18 | "devDependencies": { 19 | "@types/google-apps-script": "^1.0.34", 20 | "autoprefixer": "^7.1.2", 21 | "babel-core": "^6.22.1", 22 | "babel-helper-vue-jsx-merge-props": "^2.0.3", 23 | "babel-loader": "^7.1.1", 24 | "babel-plugin-syntax-jsx": "^6.18.0", 25 | "babel-plugin-transform-runtime": "^6.22.0", 26 | "babel-plugin-transform-vue-jsx": "^3.5.0", 27 | "babel-preset-env": "^1.3.2", 28 | "babel-preset-stage-2": "^6.22.0", 29 | "chalk": "^2.0.1", 30 | "clasp": "^1.0.0", 31 | "copy-webpack-plugin": "^4.0.1", 32 | "css-loader": "^0.28.0", 33 | "extract-text-webpack-plugin": "^3.0.0", 34 | "file-loader": "^1.1.4", 35 | "friendly-errors-webpack-plugin": "^1.6.1", 36 | "html-webpack-plugin": "^2.30.1", 37 | "node-notifier": "^5.1.2", 38 | "optimize-css-assets-webpack-plugin": "^3.2.0", 39 | "ora": "^1.2.0", 40 | "portfinder": "^1.0.13", 41 | "postcss-import": "^11.0.0", 42 | "postcss-loader": "^2.0.8", 43 | "postcss-url": "^7.2.1", 44 | "rimraf": "^2.6.0", 45 | "semver": "^5.3.0", 46 | "shelljs": "^0.7.6", 47 | "uglifyjs-webpack-plugin": "^1.1.1", 48 | "url-loader": "^0.5.8", 49 | "vue-loader": "^13.3.0", 50 | "vue-style-loader": "^3.0.1", 51 | "vue-template-compiler": "^2.5.2", 52 | "webpack": "^3.6.0", 53 | "webpack-bundle-analyzer": "^2.9.0", 54 | "webpack-dev-server": "^2.9.1", 55 | "webpack-merge": "^4.1.0" 56 | }, 57 | "engines": { 58 | "node": ">= 6.0.0", 59 | "npm": ">= 3.0.0" 60 | }, 61 | "browserslist": [ 62 | "> 1%", 63 | "last 2 versions", 64 | "not ie <= 8" 65 | ] 66 | } 67 | -------------------------------------------------------------------------------- /src/router/index.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import Router from 'vue-router' 3 | import { getToken } from '@/utils' 4 | 5 | import Home from '@/views/Home' 6 | import Signin from '@/views/Signin' 7 | import Users from '@/views/Users' 8 | import { cat } from 'shelljs' 9 | 10 | Vue.use(Router) 11 | 12 | const router = new Router({ 13 | mode: 'hash', 14 | routes: [ 15 | 16 | { 17 | path: '/', 18 | name: 'home', 19 | component: Home, 20 | meta: { 21 | requiresAuth: true, 22 | }, 23 | }, 24 | { 25 | path: '/signin', 26 | name: 'signin', 27 | component: Signin, 28 | meta: { 29 | requiresAuth: false, 30 | }, 31 | }, 32 | { 33 | path: '/users', 34 | name: 'users', 35 | component: Users, 36 | meta: { 37 | requiresAuth: true, 38 | }, 39 | } 40 | ] 41 | }) 42 | 43 | router.beforeEach((to, from, next) => { 44 | const token = getToken(); 45 | if (token === null) { 46 | if (to.name !== 'signin') next('/signin') 47 | else next() 48 | return 49 | } 50 | try { 51 | google.script.run 52 | .withSuccessHandler((response) => { 53 | const { success, message, data } = JSON.parse(response); 54 | console.log({ success, message, data }) 55 | if (to.name !== 'signin' && !success) next('/signin') 56 | else next() 57 | }) 58 | .withFailureHandler((err) => { 59 | console.log({ error: err.message }) 60 | if (to.name !== 'signin') next('/signin') 61 | else next() 62 | }) 63 | .validateToken(token); 64 | } catch (err) { 65 | if (to.name !== 'signin' && !token) next('/signin') 66 | else next() 67 | } 68 | }) 69 | router.afterEach((to, from) => { 70 | try { 71 | const stateObject = {} 72 | const params = {} 73 | google.script.history.push(stateObject, params, to.name) 74 | } catch (err) { 75 | //pass 76 | } 77 | }) 78 | export default router -------------------------------------------------------------------------------- /config/index.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | // Template version: 1.3.1 3 | // see http://vuejs-templates.github.io/webpack for documentation. 4 | 5 | const path = require('path') 6 | 7 | module.exports = { 8 | dev: { 9 | 10 | // Paths 11 | assetsSubDirectory: 'static', 12 | assetsPublicPath: '/', 13 | proxyTable: {}, 14 | 15 | // Various Dev Server settings 16 | host: 'localhost', // can be overwritten by process.env.HOST 17 | port: 8080, // can be overwritten by process.env.PORT, if port is in use, a free one will be determined 18 | autoOpenBrowser: false, 19 | errorOverlay: true, 20 | notifyOnErrors: true, 21 | poll: false, // https://webpack.js.org/configuration/dev-server/#devserver-watchoptions- 22 | 23 | 24 | /** 25 | * Source Maps 26 | */ 27 | 28 | // https://webpack.js.org/configuration/devtool/#development 29 | devtool: 'cheap-module-eval-source-map', 30 | 31 | // If you have problems debugging vue-files in devtools, 32 | // set this to false - it *may* help 33 | // https://vue-loader.vuejs.org/en/options.html#cachebusting 34 | cacheBusting: true, 35 | 36 | cssSourceMap: true 37 | }, 38 | 39 | build: { 40 | // Template for index.html 41 | index: path.resolve(__dirname, '../dist/index.html'), 42 | 43 | // Paths 44 | assetsRoot: path.resolve(__dirname, '../dist'), 45 | assetsSubDirectory: 'static', 46 | assetsPublicPath: '/', 47 | 48 | /** 49 | * Source Maps 50 | */ 51 | 52 | productionSourceMap: true, 53 | // https://webpack.js.org/configuration/devtool/#production 54 | devtool: '#source-map', 55 | 56 | // Gzip off by default as many popular static hosts such as 57 | // Surge or Netlify already gzip all static assets for you. 58 | // Before setting to `true`, make sure to: 59 | // npm install --save-dev compression-webpack-plugin 60 | productionGzip: false, 61 | productionGzipExtensions: ['js', 'css'], 62 | 63 | // Run the build command with an extra argument to 64 | // View the bundle analyzer report after build finishes: 65 | // `npm run build --report` 66 | // Set to `true` or `false` to always turn it on or off 67 | bundleAnalyzerReport: process.env.npm_config_report 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/views/Signin.vue: -------------------------------------------------------------------------------- 1 | 39 | 40 | 75 | 76 | -------------------------------------------------------------------------------- /src/store/modules/auth.js: -------------------------------------------------------------------------------- 1 | import { TEST_USER, getToken, setToken, removeToken, runApi } from '@/utils.js' 2 | import router from '@/router' 3 | 4 | const state = () => ({ 5 | user: null, 6 | token: null, 7 | defaultUser: { 8 | id: 1, 9 | name: "Ashton Fei", 10 | gender: "Male", 11 | email: "yunjia.fei@gmail.com", 12 | role: "admin", 13 | status: "active", 14 | token: null, 15 | }, 16 | default_token: 'ashton.fei@gmail.com.password', 17 | }) 18 | 19 | const getters = { 20 | } 21 | 22 | const actions = { 23 | checkUserAuth: ({ commit, state }) => { 24 | const token = getToken(); 25 | if (token === null || token === "null") return this.$router.push("/signin"); 26 | try { 27 | runApi("validateToken", token) 28 | .then(({ success, message, data }) => { 29 | if (!success) return alert(message); 30 | this.$store.commit("user/setUser", data); 31 | }) 32 | .catch(({ success, message }) => { 33 | console.err(message); 34 | }) 35 | // google.script.run 36 | // .withSuccessHandler((response) => { 37 | // const { success, message, data } = JSON.parse(response); 38 | // if (!success) return alert(message); 39 | // this.$store.commit("user/setUser", data); 40 | // }) 41 | // .withFailureHandler((err) => { 42 | // alert(err.message); 43 | // }) 44 | // .validateToken(token); 45 | } catch (err) { 46 | this.$store.commit("user/setUser", TEST_USER); 47 | } 48 | }, 49 | signin: ({ state, commit }, { email, password }) => { 50 | try { 51 | runApi("signin", { email, password }) 52 | .then(({ success, message, data }) => { 53 | if (!success) return console.error(message) 54 | commit('setUser', data) 55 | router.push('/') 56 | }) 57 | .catch(({ message }) => { 58 | console.error(message) 59 | }) 60 | } catch (err) { 61 | if (`${email}.${password}` !== state.default_token) { 62 | alert('Your credentials are not current.') 63 | return 64 | } 65 | console.log("You are signed in.") 66 | commit('setUser', TEST_USER) 67 | router.push('/') 68 | } 69 | }, 70 | signout: ({ commit }) => { 71 | commit('setUser', null) 72 | router.push('/signin') 73 | }, 74 | } 75 | 76 | const mutations = { 77 | setUser: (state, data) => { 78 | state.user = data 79 | if (data == null) return removeToken() 80 | if (data.token) setToken(data.token) 81 | }, 82 | } 83 | 84 | export default { 85 | namespaced: true, 86 | state, 87 | getters, 88 | actions, 89 | mutations, 90 | } -------------------------------------------------------------------------------- /src/views/Home.vue: -------------------------------------------------------------------------------- 1 | 91 | 92 | 114 | 115 | -------------------------------------------------------------------------------- /gas/api.js: -------------------------------------------------------------------------------- 1 | class JWT { 2 | constructor() { } 3 | /** 4 | * @param {object} payload - an json object 5 | */ 6 | static createToken(payload) { 7 | const header = Utilities.base64EncodeWebSafe(JSON.stringify({ alg: "HS256", typ: "JWT" })) 8 | payload = Utilities.base64EncodeWebSafe(JSON.stringify(payload)) 9 | const signature = Utilities.computeHmacSha256Signature(`${header}.${payload}`, SETTINGS.SECRECT) 10 | return `${header}.${payload}.${Utilities.base64EncodeWebSafe(signature)}` 11 | } 12 | 13 | /** 14 | * @param {string} token - header.payload.signature 15 | */ 16 | static isValidToken(token) { 17 | const [header, payload, signature] = token.split(".") 18 | const validSignature = Utilities.base64EncodeWebSafe(Utilities.computeHmacSha256Signature(`${header}.${payload}`, SETTINGS.SECRECT)) 19 | return signature === validSignature 20 | } 21 | } 22 | 23 | class Session { 24 | constructor() { 25 | } 26 | 27 | static generateSessionId() { 28 | return Utilities.base64Encode(Utilities.getUuid()) 29 | } 30 | 31 | static getSession(sid) { 32 | const session = CacheService.getScriptCache().get(sid) 33 | if (session === null) return session 34 | return this.createSession(sid, session) 35 | } 36 | 37 | static createSession(key, session) { 38 | CacheService.getScriptCache().put(key, session, SETTINGS.SESSION_EXPRATION_IN_SECONDS) 39 | return session 40 | } 41 | 42 | static deleteSession(sid) { 43 | CacheService.getScriptCache().remove(sid) 44 | } 45 | } 46 | 47 | 48 | class SSDB { 49 | constructor() { } 50 | /** 51 | * @description get all items from a table 52 | * @param {sheet} table worksheet object 53 | * e.g. SpreadsheetApp.getActive().getSheetByName() 54 | * @returns an array of objects 55 | */ 56 | static getAllItems(table) { 57 | let [keys, ...values] = table.getDataRange().getValues() 58 | keys = keys.map(v => v.toString().trim()) 59 | return values.map(v => { 60 | const item = {} 61 | keys.forEach((key, i) => { 62 | if (key) item[key] = v[i] 63 | }) 64 | return item 65 | }) 66 | } 67 | 68 | /** 69 | * @description get the first match item by filters from a table 70 | * @param {sheet} table worksheet object 71 | * e.g. SpreadsheetApp.getActive().getSheetByName() 72 | * 73 | * @param {object} filters an object of key:value pairs 74 | * default is null (get all data without any filters) 75 | * e.g. {name: "Ashton Fei", role: ["admin", "staff"]} 76 | * note: value can be an array for multiple values in one column 77 | * 78 | * @returns a single item found or undefined 79 | */ 80 | static getItemByFilters(table, filters) { 81 | const items = this.getAllItems(table) 82 | return items.find(item => { 83 | const results = Object.keys(filters).map(key => { 84 | const itemValue = item[key] 85 | const filterValue = filters[key] 86 | if (Array.isArray(filterValue)) return filterValue.includes(itemValue) 87 | return itemValue === filterValue 88 | }) 89 | return !results.includes(false) 90 | }) 91 | } 92 | 93 | /** 94 | * @description get items by filters from a table 95 | * @param {sheet} table worksheet object 96 | * e.g. SpreadsheetApp.getActive().getSheetByName() 97 | * 98 | * @param {object} filters an object of key:value pairs 99 | * default is null (get all data without any filters) 100 | * e.g. {name: "Ashton Fei", role: ["admin", "staff"]} 101 | * note: value can be an array for multiple values in one column 102 | * 103 | * @returns an array of items found or empty array 104 | */ 105 | static getItemsByFilters(table, filters) { 106 | const items = this.getAllItems(table) 107 | return items.filter(item => { 108 | const results = Object.keys(filters).map(key => { 109 | const itemValue = item[key] 110 | const filterValue = filters[key] 111 | if (Array.isArray(filterValue)) return filterValue.includes(itemValue) 112 | return itemValue === filterValue 113 | }) 114 | return !results.includes(false) 115 | }) 116 | } 117 | } 118 | 119 | class API { 120 | constructor() { 121 | } 122 | /** 123 | * @param {string} name - name of the table(tab) in the database(spreadsheet) 124 | */ 125 | static getTableByName(name) { 126 | let db 127 | const response = { success: true, message: `${name} is found in the database.`, table: null } 128 | try { 129 | db = SpreadsheetApp.openById(SETTINGS.SSDB_ID) 130 | } catch (e) { 131 | response.success = false 132 | response.message = `${e.message}` 133 | return response 134 | } 135 | const table = db.getSheetByName(name) 136 | if (!table) { 137 | response.success = false 138 | response.message = `${name} was not found in database.` 139 | } else { 140 | response.table = table 141 | } 142 | return response 143 | } 144 | 145 | /** 146 | * @param {string} tableName the name of table(tab) in the database(spreadsheet) 147 | * e.g. users 148 | * @param {object} filters an object of key:value pairs 149 | * default is null (get all data without any filters) 150 | * e.g. {name: "Ashton Fei", role: ["admin", "staff"]} 151 | * note: value can be an array for multiple values in one column 152 | * @returns an array of objects or empty array 153 | */ 154 | static get(tableName, filters = null) { 155 | const response = { 156 | success: true, 157 | message: "Items have been retrieved from the database.", 158 | data: null, 159 | } 160 | const { success, message, table } = this.getTableByName(tableName) 161 | if (!table) return { ...response, success, message } 162 | if (!filters) return { ...response, data: SSDB.getAllItems(table) } 163 | return { ...response, data: SSDB.getItemsByFilters(table, filters) } 164 | } 165 | 166 | /** 167 | * @param {string} tableName the name of table(tab) in the database(spreadsheet) 168 | * e.g. users 169 | * @param {object} filters an object of key:value pairs 170 | * default is null (get all data without any filters) 171 | * e.g. {name: "Ashton Fei", role: ["admin", "staff"]} 172 | * note: value can be an array for multiple values in one column 173 | */ 174 | static post(tableName, filters = []) { 175 | const { success, message, table } = this.getTableByName(tableName) 176 | if (!table) return { success, message } 177 | return { 178 | success, 179 | message: "Items have been posted from the database.", 180 | data: table.getDataRange().getValues() 181 | } 182 | } 183 | 184 | /** 185 | * @param {string} tableName the name of table(tab) in the database(spreadsheet) 186 | * e.g. users 187 | * @param {object} filters an object of key:value pairs 188 | * default is null (get all data without any filters) 189 | * e.g. {name: "Ashton Fei", role: ["admin", "staff"]} 190 | * note: value can be an array for multiple values in one column 191 | */ 192 | static delete(tableName, filters = []) { 193 | const { success, message, table } = this.getTableByName(tableName) 194 | if (!table) return { success, message } 195 | return { 196 | success, 197 | message: "Items have been deleted from the database.", 198 | data: table.getDataRange().getValues() 199 | } 200 | } 201 | } 202 | 203 | class Auth { 204 | constructor() { 205 | 206 | } 207 | 208 | static hashPassword(password) { 209 | const signature = Utilities.computeHmacSha256Signature(password, SETTINGS.SECRECT) 210 | return Utilities.base64EncodeWebSafe(signature) 211 | } 212 | 213 | static validatePassword(password, correctHashPassword) { 214 | return this.hashPassword(password) === correctHashPassword 215 | } 216 | 217 | static validateToken(token) { 218 | return JWT.isValidToken(token) ? { 219 | success: true, 220 | message: "Token is valid.", 221 | data: JSON.parse(Utilities.newBlob(Utilities.base64DecodeWebSafe(token.split(".")[1])).getDataAsString()), 222 | token, 223 | } : { 224 | success: false, 225 | message: "Token is invalid.", 226 | data: null, 227 | token: null, 228 | } 229 | } 230 | 231 | static singin(email, password) { 232 | const response = { 233 | success: true, 234 | message: 'You are signed in successfully.', 235 | data: null 236 | } 237 | 238 | // get users by email address 239 | const { success, message, data } = API.get(SETTINGS.AUTH_TABLE_NAME, { email }) 240 | if (!success) return { ...response, success, message } 241 | 242 | // if user is not found 243 | const user = data[0] 244 | if (!user) return { ...response, success: false, message: "Your credentials are not correct." } 245 | 246 | // if password is not valid 247 | const isPasswordValid = this.validatePassword(password, user.password) 248 | if (!isPasswordValid) return { ...response, success: false, message: "Your credentials are not correct." } 249 | 250 | user.token = JWT.createToken(user) 251 | return { ...response, data: user } 252 | } 253 | 254 | static signout(token) { 255 | return { success: true, message: "You have been signed out.", data: SETTINGS.ROOT_URL } 256 | } 257 | } --------------------------------------------------------------------------------