From f5fb21aaba4d5dd4952ed6bdfde6bb5dee337645 Mon Sep 17 00:00:00 2001 From: NGPixel Date: Mon, 5 Mar 2018 20:53:24 -0500 Subject: [PATCH] feat: vue-apollo + auth providers resolver (wip) --- client/app.js | 41 +++++++------------- client/components/admin-api.vue | 3 ++ client/components/admin-auth.vue | 32 ++++++--------- client/components/nav-header.vue | 2 +- client/constants/graphql.js | 28 ++++++++----- client/constants/index.js | 4 +- package.json | 4 +- server/app/data.yml | 2 +- server/core/auth.js | 26 +++++++------ server/graph/resolvers/authentication.js | 27 +++++-------- server/graph/schemas/authentication.graphql | 10 ++++- server/graph/schemas/common.graphql | 20 ++++++++++ server/index.js | 4 -- yarn.lock | Bin 384571 -> 371614 bytes 14 files changed, 104 insertions(+), 99 deletions(-) diff --git a/client/app.js b/client/app.js index 44f856b9..19673ad1 100644 --- a/client/app.js +++ b/client/app.js @@ -10,10 +10,9 @@ import VueClipboards from 'vue-clipboards' import VueSimpleBreakpoints from 'vue-simple-breakpoints' import VeeValidate from 'vee-validate' import { ApolloClient } from 'apollo-client' -import { ApolloLink } from 'apollo-link' -import { createApolloFetch } from 'apollo-fetch' import { BatchHttpLink } from 'apollo-link-batch-http' import { InMemoryCache } from 'apollo-cache-inmemory' +import VueApollo from 'vue-apollo' import Vuetify from 'vuetify' import Velocity from 'velocity-animate' import Hammer from 'hammerjs' @@ -36,7 +35,7 @@ import helpers from './helpers' // Initialize Global Vars // ==================================== -window.wiki = null +window.WIKI = null window.boot = boot window.CONSTANTS = CONSTANTS window.Hammer = Hammer @@ -47,31 +46,11 @@ window.Hammer = Hammer const graphQLEndpoint = window.location.protocol + '//' + window.location.host + siteConfig.path + 'graphql' -const apolloFetch = createApolloFetch({ - uri: graphQLEndpoint, - constructOptions: (requestOrRequests, options) => ({ - ...options, - method: 'POST', - body: JSON.stringify(requestOrRequests), - credentials: 'include' - }) -}) - window.graphQL = new ApolloClient({ - link: ApolloLink.from([ - new ApolloLink((operation, forward) => { - operation.setContext({ - headers: { - 'Content-Type': 'application/json' - } - }) - - return forward(operation) - }), - new BatchHttpLink({ - fetch: apolloFetch - }) - ]), + link: new BatchHttpLink({ + uri: graphQLEndpoint, + credentials: 'include' + }), cache: new InMemoryCache(), connectToDevTools: (process.env.node_env === 'development') }) @@ -81,6 +60,7 @@ window.graphQL = new ApolloClient({ // ==================================== Vue.use(VueRouter) +Vue.use(VueApollo) Vue.use(VueClipboards) Vue.use(VueSimpleBreakpoints) Vue.use(localization.VueI18Next) @@ -121,15 +101,20 @@ let bootstrap = () => { store.dispatch('startLoading') }) + const apolloProvider = new VueApollo({ + defaultClient: window.graphQL + }) + // ==================================== // Bootstrap Vue // ==================================== const i18n = localization.init() - window.wiki = new Vue({ + window.WIKI = new Vue({ el: '#app', components: {}, mixins: [helpers], + provide: apolloProvider.provide(), store, i18n }) diff --git a/client/components/admin-api.vue b/client/components/admin-api.vue index 9b2e03b0..40c70faf 100644 --- a/client/components/admin-api.vue +++ b/client/components/admin-api.vue @@ -5,6 +5,9 @@ .subheading.grey--text Manage keys to access the API v-card v-card-title + v-btn(color='green', dark) + v-icon(left) power_settings_new + | Enable API v-btn(color='primary', dark) v-icon(left) add | New API Key diff --git a/client/components/admin-auth.vue b/client/components/admin-auth.vue index 25b56f7d..88a483a8 100644 --- a/client/components/admin-auth.vue +++ b/client/components/admin-auth.vue @@ -6,24 +6,12 @@ .subheading.grey--text Configure the authentication settings of your wiki v-tabs(color='grey lighten-4', grow, slider-color='primary', show-arrows) v-tab(key='settings'): v-icon settings - v-tab(key='db') Local - v-tab(key='algolia') Auth0 - v-tab(key='elasticsearch') Azure AD - v-tab(key='solr') Discord - v-tab(key='solr') Dropbox - v-tab(key='solr') Facebook - v-tab(key='solr') GitHub - v-tab(key='solr') Google - v-tab(key='solr') LDAP - v-tab(key='solr') Microsoft - v-tab(key='solr') OAuth2 Generic - v-tab(key='solr') Slack - v-tab(key='solr') Twitch + v-tab(v-for='provider in providers', :key='provider.key') {{ provider.title }} v-tab-item(key='settings') v-card.pa-3 v-form - v-checkbox(v-for='(engine, n) in engines', v-model='auths', :key='n', :label='engine.text', :value='engine.value', color='primary') + v-checkbox(v-for='(provider, n) in providers', v-model='auths', :key='provider.key', :label='provider.title', :value='provider.key', color='primary') v-divider v-btn(color='primary') v-icon(left) chevron_right @@ -34,18 +22,20 @@ diff --git a/client/components/nav-header.vue b/client/components/nav-header.vue index 3e21f7a2..2c865346 100644 --- a/client/components/nav-header.vue +++ b/client/components/nav-header.vue @@ -4,7 +4,7 @@ v-toolbar-title span.subheading Wiki.js v-spacer - v-progress-circular.mr-3(indeterminate, color='blue') + v-progress-circular.mr-3(indeterminate, color='blue', v-if='$apollo.loading') v-btn(icon) v-icon(color='grey') search v-btn(icon, @click.native='darkTheme = !darkTheme') diff --git a/client/constants/graphql.js b/client/constants/graphql.js index ea6ddc3e..32e6eba3 100644 --- a/client/constants/graphql.js +++ b/client/constants/graphql.js @@ -1,16 +1,26 @@ import gql from 'graphql-tag' export default { - GQL_QUERY_AUTHENTICATION: gql` - query($mode: String!) { - authentication(mode:$mode) { - key - useForm - title - icon + AUTHENTICATION: { + QUERY_PROVIDERS: gql` + query { + authentication { + providers { + isEnabled + key + props + title + useForm + icon + config { + key + value + } + } + } } - } - `, + ` + }, GQL_QUERY_TRANSLATIONS: gql` query($locale: String!, $namespace: String!) { translations(locale:$locale, namespace:$namespace) { diff --git a/client/constants/index.js b/client/constants/index.js index 56e9acc7..267f49dd 100644 --- a/client/constants/index.js +++ b/client/constants/index.js @@ -1,5 +1,5 @@ -import GRAPHQL from './graphql' +import GRAPH from './graphql' export default { - GRAPHQL + GRAPH } diff --git a/package.json b/package.json index de59c40a..ae14b354 100644 --- a/package.json +++ b/package.json @@ -135,7 +135,6 @@ "yargs": "11.0.0" }, "devDependencies": { - "@glimpse/glimpse": "0.22.15", "@panter/vue-i18next": "0.9.1", "apollo-client-preset": "1.0.8", "apollo-fetch": "0.7.0", @@ -198,6 +197,7 @@ "vee-validate": "2.0.5", "velocity-animate": "1.5.1", "vue": "2.5.13", + "vue-apollo": "3.0.0-beta.4", "vue-clipboards": "1.2.2", "vue-codemirror": "4.0.3", "vue-hot-reload-api": "2.3.0", @@ -211,7 +211,7 @@ "vuex-persistedstate": "2.4.2", "webpack": "3.11.0", "webpack-bundle-analyzer": "2.11.1", - "webpack-dev-middleware": "3.0.0", + "webpack-dev-middleware": "2.0.3", "webpack-hot-middleware": "2.21.2", "webpack-merge": "4.1.2", "whatwg-fetch": "2.0.3", diff --git a/server/app/data.yml b/server/app/data.yml index dd3daecc..5d1a656c 100644 --- a/server/app/data.yml +++ b/server/app/data.yml @@ -31,7 +31,7 @@ defaults: public: false strategies: local: - enabled: true + isEnabled: true allowSelfRegister: false git: enabled: false diff --git a/server/core/auth.js b/server/core/auth.js index 16ebb471..c23ac426 100644 --- a/server/core/auth.js +++ b/server/core/auth.js @@ -4,6 +4,7 @@ const _ = require('lodash') const passport = require('passport') const fs = require('fs-extra') const path = require('path') +const autoload = require('auto-load') module.exports = { strategies: {}, @@ -31,26 +32,29 @@ module.exports = { // Load authentication strategies - _.forOwn(_.omitBy(WIKI.config.auth.strategies, s => s.enabled === false), (strategyConfig, strategyKey) => { - strategyConfig.callbackURL = `${WIKI.config.site.host}${WIKI.config.site.path}login/${strategyKey}/callback` - let strategy = require(`../modules/authentication/${strategyKey}`) - try { - strategy.init(passport, strategyConfig) - } catch (err) { - WIKI.logger.error(`Authentication Provider ${strategyKey}: [ FAILED ]`) - WIKI.logger.error(err) + const modules = _.values(autoload(path.join(WIKI.SERVERPATH, 'modules/authentication'))) + _.forEach(modules, (strategy) => { + const strategyConfig = _.get(WIKI.config.auth.strategies, strategy.key, {}) + strategyConfig.callbackURL = `${WIKI.config.site.host}${WIKI.config.site.path}login/${strategy.key}/callback` + if (strategyConfig.isEnabled) { + try { + strategy.init(passport, strategyConfig) + } catch (err) { + WIKI.logger.error(`Authentication Provider ${strategy.title}: [ FAILED ]`) + WIKI.logger.error(err) + } } - fs.readFile(path.join(WIKI.ROOTPATH, `assets/svg/auth-icon-${strategyKey}.svg`), 'utf8').then(iconData => { + fs.readFile(path.join(WIKI.ROOTPATH, `assets/svg/auth-icon-${strategy.key}.svg`), 'utf8').then(iconData => { strategy.icon = iconData }).catch(err => { if (err.code === 'ENOENT') { strategy.icon = '[missing icon]' } else { - WIKI.logger.error(err) + WIKI.logger.warn(err) } }) this.strategies[strategy.key] = strategy - WIKI.logger.info(`Authentication Provider ${strategyKey}: [ OK ]`) + WIKI.logger.info(`Authentication Provider ${strategy.title}: [ OK ]`) }) // Create Guest account for first-time diff --git a/server/graph/resolvers/authentication.js b/server/graph/resolvers/authentication.js index 0145845f..289a7c5d 100644 --- a/server/graph/resolvers/authentication.js +++ b/server/graph/resolvers/authentication.js @@ -13,28 +13,19 @@ module.exports = { }, AuthenticationQuery: { providers(obj, args, context, info) { - switch (args.mode) { - case 'active': - let strategies = _.chain(WIKI.auth.strategies).map(str => { - return { - key: str.key, - title: str.title, - useForm: str.useForm - } - }).sortBy(['title']).value() - let localStrategy = _.remove(strategies, str => str.key === 'local') - return _.concat(localStrategy, strategies) - case 'all': - - break - default: - return null - } + return _.chain(WIKI.auth.strategies).map(str => { + return { + isEnabled: true, + key: str.key, + title: str.title, + useForm: str.useForm + } + }).sortBy(['title']).value() } }, AuthenticationProvider: { icon (ap, args) { - return fs.readFileAsync(path.join(WIKI.ROOTPATH, `assets/svg/auth-icon-${ap.key}.svg`), 'utf8').catch(err => { + return fs.readFile(path.join(WIKI.ROOTPATH, `assets/svg/auth-icon-${ap.key}.svg`), 'utf8').catch(err => { if (err.code === 'ENOENT') { return null } diff --git a/server/graph/schemas/authentication.graphql b/server/graph/schemas/authentication.graphql index b808f26a..a8afea5d 100644 --- a/server/graph/schemas/authentication.graphql +++ b/server/graph/schemas/authentication.graphql @@ -10,7 +10,13 @@ type AuthenticationQuery { providers: [AuthenticationProvider] } -type AuthenticationMutation +type AuthenticationMutation { + updateProvider( + provider: String! + isEnabled: Boolean! + config: [KeyValuePairInput] + ): DefaultResponse +} type AuthenticationProvider { isEnabled: Boolean! @@ -19,5 +25,5 @@ type AuthenticationProvider { title: String! useForm: Boolean! icon: String - config: String + config: [KeyValuePair] } diff --git a/server/graph/schemas/common.graphql b/server/graph/schemas/common.graphql index 6e3b12a3..9d9058bb 100644 --- a/server/graph/schemas/common.graphql +++ b/server/graph/schemas/common.graphql @@ -29,6 +29,26 @@ interface Base { # TYPES +type KeyValuePair { + key: String! + value: String! +} +input KeyValuePairInput { + key: String! + value: String! +} + +type DefaultResponse { + operation: ResponseStatus +} + +type ResponseStatus { + succeeded: Boolean! + code: Int! + slug: String! + message: String +} + type Comment implements Base { id: Int! createdAt: Date diff --git a/server/index.js b/server/index.js index de96485a..ccd21d1a 100644 --- a/server/index.js +++ b/server/index.js @@ -17,10 +17,6 @@ let WIKI = { } global.WIKI = WIKI -// if (WIKI.IS_DEBUG) { -// require('@glimpse/glimpse').init() -// } - WIKI.configSvc.init() // ---------------------------------------- diff --git a/yarn.lock b/yarn.lock index f51158ad2afe657b494e567f14f6325959e30191..2a6d4b23c1ae296babae59e0d289fd9c2c8338aa 100644 GIT binary patch delta 1157 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