feat: admin auth + config ref + modules sidebar ui + GQL upload (wip)

This commit is contained in:
Nick 2019-04-20 20:49:05 -04:00
parent 596833180e
commit 6fe49309c1
19 changed files with 597 additions and 350 deletions

View File

@ -5,11 +5,10 @@ import VueRouter from 'vue-router'
import VueClipboards from 'vue-clipboards' import VueClipboards from 'vue-clipboards'
import VeeValidate from 'vee-validate' import VeeValidate from 'vee-validate'
import { ApolloClient } from 'apollo-client' import { ApolloClient } from 'apollo-client'
import { createPersistedQueryLink } from 'apollo-link-persisted-queries'
import { BatchHttpLink } from 'apollo-link-batch-http' import { BatchHttpLink } from 'apollo-link-batch-http'
import { ApolloLink, split } from 'apollo-link' import { ApolloLink, split } from 'apollo-link'
// import { createHttpLink } from 'apollo-link-http'
import { WebSocketLink } from 'apollo-link-ws' import { WebSocketLink } from 'apollo-link-ws'
import { createUploadLink } from 'apollo-upload-client'
import { ErrorLink } from 'apollo-link-error' import { ErrorLink } from 'apollo-link-error'
import { InMemoryCache } from 'apollo-cache-inmemory' import { InMemoryCache } from 'apollo-cache-inmemory'
import { getMainDefinition } from 'apollo-utilities' import { getMainDefinition } from 'apollo-utilities'
@ -56,6 +55,33 @@ store.commit('user/REFRESH_AUTH')
const graphQLEndpoint = window.location.protocol + '//' + window.location.host + '/graphql' const graphQLEndpoint = window.location.protocol + '//' + window.location.host + '/graphql'
const graphQLWSEndpoint = ((window.location.protocol === 'https:') ? 'wss:' : 'ws:') + '//' + window.location.host + '/graphql-subscriptions' const graphQLWSEndpoint = ((window.location.protocol === 'https:') ? 'wss:' : 'ws:') + '//' + window.location.host + '/graphql-subscriptions'
const graphQLFetch = async (uri, options) => {
// Strip __typename fields from variables
let body = JSON.parse(options.body)
body = body.map(bd => {
return ({
...bd,
variables: JSON.parse(JSON.stringify(bd.variables), (key, value) => { return key === '__typename' ? undefined : value })
})
})
options.body = JSON.stringify(body)
// Inject authentication token
const jwtToken = Cookies.get('jwt')
if (jwtToken) {
options.headers.Authorization = `Bearer ${jwtToken}`
}
const resp = await fetch(uri, options)
// Handle renewed JWT
const newJWT = resp.headers.get('new-jwt')
if (newJWT) {
Cookies.set('jwt', newJWT, { expires: 365 })
}
return resp
}
const graphQLLink = ApolloLink.from([ const graphQLLink = ApolloLink.from([
new ErrorLink(({ graphQLErrors, networkError }) => { new ErrorLink(({ graphQLErrors, networkError }) => {
if (graphQLErrors) { if (graphQLErrors) {
@ -79,7 +105,6 @@ const graphQLLink = ApolloLink.from([
}) })
} }
}), }),
// createPersistedQueryLink(),
new BatchHttpLink({ new BatchHttpLink({
includeExtensions: true, includeExtensions: true,
uri: graphQLEndpoint, uri: graphQLEndpoint,
@ -93,10 +118,6 @@ const graphQLLink = ApolloLink.from([
variables: JSON.parse(JSON.stringify(bd.variables), (key, value) => { return key === '__typename' ? undefined : value }) variables: JSON.parse(JSON.stringify(bd.variables), (key, value) => { return key === '__typename' ? undefined : value })
}) })
}) })
// body = {
// ...body,
// variables: JSON.parse(JSON.stringify(body.variables), (key, value) => { return key === '__typename' ? undefined : value })
// }
options.body = JSON.stringify(body) options.body = JSON.stringify(body)
// Inject authentication token // Inject authentication token
@ -117,6 +138,28 @@ const graphQLLink = ApolloLink.from([
}) })
]) ])
const graphQLUploadLink = createUploadLink({
includeExtensions: true,
uri: graphQLEndpoint,
credentials: 'include',
fetch: async (uri, options) => {
// Inject authentication token
const jwtToken = Cookies.get('jwt')
if (jwtToken) {
options.headers.Authorization = `Bearer ${jwtToken}`
}
const resp = await fetch(uri, options)
// Handle renewed JWT
const newJWT = resp.headers.get('new-jwt')
if (newJWT) {
Cookies.set('jwt', newJWT, { expires: 365 })
}
return resp
}
})
const graphQLWSLink = new WebSocketLink({ const graphQLWSLink = new WebSocketLink({
uri: graphQLWSEndpoint, uri: graphQLWSEndpoint,
options: { options: {
@ -129,7 +172,7 @@ window.graphQL = new ApolloClient({
link: split(({ query }) => { link: split(({ query }) => {
const { kind, operation } = getMainDefinition(query) const { kind, operation } = getMainDefinition(query)
return kind === 'OperationDefinition' && operation === 'subscription' return kind === 'OperationDefinition' && operation === 'subscription'
}, graphQLWSLink, graphQLLink), }, graphQLWSLink, split(operation => operation.getContext().hasUpload, graphQLUploadLink, graphQLLink)),
cache: new InMemoryCache(), cache: new InMemoryCache(),
connectToDevTools: (process.env.node_env === 'development') connectToDevTools: (process.env.node_env === 'development')
}) })

View File

@ -49,7 +49,7 @@
v-list-tile(to='/auth') v-list-tile(to='/auth')
v-list-tile-avatar: v-icon lock_outline v-list-tile-avatar: v-icon lock_outline
v-list-tile-title {{ $t('admin:auth.title') }} v-list-tile-title {{ $t('admin:auth.title') }}
v-list-tile(to='/editor') v-list-tile(to='/editor', disabled)
v-list-tile-avatar: v-icon transform v-list-tile-avatar: v-icon transform
v-list-tile-title {{ $t('admin:editor.title') }} v-list-tile-title {{ $t('admin:editor.title') }}
v-list-tile(to='/logging') v-list-tile(to='/logging')

View File

@ -6,7 +6,7 @@
img.animated.fadeInUp(src='/svg/icon-unlock.svg', alt='Authentication', style='width: 80px;') img.animated.fadeInUp(src='/svg/icon-unlock.svg', alt='Authentication', style='width: 80px;')
.admin-header-title .admin-header-title
.headline.primary--text.animated.fadeInLeft Authentication .headline.primary--text.animated.fadeInLeft Authentication
.subheading.grey--text.animated.fadeInLeft.wait-p4s Configure the authentication settings of your wiki #[v-chip(label, color='primary', small).white--text coming soon] .subheading.grey--text.animated.fadeInLeft.wait-p4s Configure the authentication settings of your wiki
v-spacer v-spacer
v-btn.animated.fadeInDown.wait-p2s(outline, color='grey', @click='refresh', large) v-btn.animated.fadeInDown.wait-p2s(outline, color='grey', @click='refresh', large)
v-icon refresh v-icon refresh
@ -14,158 +14,186 @@
v-icon(left) check v-icon(left) check
span {{$t('common:actions.apply')}} span {{$t('common:actions.apply')}}
v-card.mt-3.animated.fadeInUp v-flex(lg3, xs12)
v-tabs(color='grey darken-2', fixed-tabs, slider-color='white', show-arrows, dark) v-card.animated.fadeInUp
v-tab(key='settings'): v-icon settings v-toolbar(flat, color='primary', dark, dense)
v-tab(v-for='strategy in activeStrategies', :key='strategy.key') {{ strategy.title }} .subheading Strategies
v-list(two-line, dense).py-0
template(v-for='(str, idx) in strategies')
v-list-tile(:key='str.key', @click='selectedStrategy = str.key', :disabled='!str.isAvailable')
v-list-tile-avatar
v-icon(color='grey', v-if='!str.isAvailable') indeterminate_check_box
v-icon(color='primary', v-else-if='str.isEnabled', v-ripple, @click='str.key !== `local` && (str.isEnabled = false)') check_box
v-icon(color='grey', v-else, v-ripple, @click='str.isEnabled = true') check_box_outline_blank
v-list-tile-content
v-list-tile-title.body-2(:class='!str.isAvailable ? `grey--text` : (selectedStrategy === str.key ? `primary--text` : ``)') {{ str.title }}
v-list-tile-sub-title.caption(:class='!str.isAvailable ? `grey--text text--lighten-1` : (selectedStrategy === str.key ? `blue--text ` : ``)') {{ str.description }}
v-list-tile-avatar(v-if='selectedStrategy === str.key')
v-icon.animated.fadeInLeft(color='primary') arrow_forward_ios
v-divider(v-if='idx < strategies.length - 1')
v-tab-item(key='settings', :transition='false', :reverse-transition='false') v-card.wiki-form.mt-3.animated.fadeInUp.wait-p2s
v-container.pa-3(fluid, grid-list-md) v-toolbar(flat, color='primary', dark, dense)
v-layout(row, wrap) .subheading Global Advanced settings
v-flex(xs12, md6) v-card-text
.body-2.grey--text.text--darken-1 Select which authentication strategies to enable: v-text-field.md2(
.caption.grey--text.pb-2 Some strategies require additional configuration in their dedicated tab (when selected). v-model='jwtAudience'
v-form outline
//- TODO - Prevent crash on unfinished strategies prepend-icon='account_balance'
v-checkbox.my-0( label='JWT Audience'
v-for='strategy in strategies' hint='Audience URN used in JWT issued upon login. Usually your domain name. (e.g. urn:your.domain.com)'
v-model='strategy.isEnabled' persistent-hint
:key='strategy.key' )
:label='strategy.title' v-text-field.mt-3.md2(
color='primary' v-model='jwtExpiration'
:disabled='strategy.key === `local` || true' outline
hide-details prepend-icon='schedule'
) label='Token Expiration'
v-flex(xs12, md6) hint='The expiration period of a token until it must be renewed. (default: 30m)'
.pa-3.grey.radius-7(:class='$vuetify.dark ? "darken-4" : "lighten-5"') persistent-hint
.body-2.grey--text.text--darken-1 Advanced Settings )
v-text-field.mt-3.md2( v-text-field.mt-3.md2(
v-model='jwtAudience' v-model='jwtRenewablePeriod'
outline outline
background-color='grey lighten-2' prepend-icon='update'
prepend-icon='account_balance' label='Token Renewal Period'
label='JWT Audience' hint='The maximum period a token can be renewed when expired. (default: 14d)'
hint='Audience URN used in JWT issued upon login. Usually your domain name. (e.g. urn:your.domain.com)' persistent-hint
persistent-hint )
)
v-text-field.mt-3.md2(
v-model='jwtExpiration'
outline
background-color='grey lighten-2'
prepend-icon='schedule'
label='Token Expiration'
hint='The expiration period of a token until it must be renewed. (default: 30m)'
persistent-hint
)
v-text-field.mt-3.md2(
v-model='jwtRenewablePeriod'
outline
background-color='grey lighten-2'
prepend-icon='update'
label='Token Renewal Period'
hint='The maximum period a token can be renewed when expired. (default: 14d)'
persistent-hint
)
v-tab-item(v-for='(strategy, n) in activeStrategies', :key='strategy.key', :transition='false', :reverse-transition='false') v-flex(xs12, lg9)
v-card.wiki-form.pa-3(flat, tile)
v-form v-card.wiki-form.animated.fadeInUp.wait-p2s
.authlogo v-toolbar(color='primary', dense, flat, dark)
img(:src='strategy.logo', :alt='strategy.title') .subheading {{strategy.title}}
v-subheader.pl-0 {{strategy.title}} v-card-text
.caption {{strategy.description}} v-form
.caption: a(:href='strategy.website') {{strategy.website}} .authlogo
v-divider.mt-3 img(:src='strategy.logo', :alt='strategy.title')
v-subheader.pl-0 Strategy Configuration .caption.pt-3 {{strategy.description}}
.body-1.ml-3(v-if='!strategy.config || strategy.config.length < 1') This strategy has no configuration options you can modify. .caption.pb-3: a(:href='strategy.website') {{strategy.website}}
template(v-else, v-for='cfg in strategy.config') v-divider.mt-3
v-select( v-subheader.pl-0 Strategy Configuration
v-if='cfg.value.type === "string" && cfg.value.enum' .body-1.ml-3(v-if='!strategy.config || strategy.config.length < 1'): em This strategy has no configuration options you can modify.
outline template(v-else, v-for='cfg in strategy.config')
background-color='grey lighten-2' v-select(
:items='cfg.value.enum' v-if='cfg.value.type === "string" && cfg.value.enum'
:key='cfg.key' outline
:label='cfg.value.title' background-color='grey lighten-2'
v-model='cfg.value.value' :items='cfg.value.enum'
prepend-icon='settings_applications' :key='cfg.key'
:hint='cfg.value.hint ? cfg.value.hint : ""' :label='cfg.value.title'
persistent-hint v-model='cfg.value.value'
:class='cfg.value.hint ? "mb-2" : ""' prepend-icon='settings_applications'
) :hint='cfg.value.hint ? cfg.value.hint : ""'
v-switch.mb-3( persistent-hint
v-else-if='cfg.value.type === "boolean"' :class='cfg.value.hint ? "mb-2" : ""'
:key='cfg.key' )
:label='cfg.value.title' v-switch.mb-3(
v-model='cfg.value.value' v-else-if='cfg.value.type === "boolean"'
color='primary' :key='cfg.key'
prepend-icon='settings_applications' :label='cfg.value.title'
:hint='cfg.value.hint ? cfg.value.hint : ""' v-model='cfg.value.value'
persistent-hint color='primary'
) prepend-icon='settings_applications'
v-text-field( :hint='cfg.value.hint ? cfg.value.hint : ""'
v-else persistent-hint
outline )
background-color='grey lighten-2' v-text-field(
:key='cfg.key' v-else
:label='cfg.value.title' outline
v-model='cfg.value.value' background-color='grey lighten-2'
prepend-icon='settings_applications' :key='cfg.key'
:hint='cfg.value.hint ? cfg.value.hint : ""' :label='cfg.value.title'
persistent-hint v-model='cfg.value.value'
:class='cfg.value.hint ? "mb-2" : ""' prepend-icon='settings_applications'
) :hint='cfg.value.hint ? cfg.value.hint : ""'
v-divider.mt-3 persistent-hint
v-subheader.pl-0 Registration :class='cfg.value.hint ? "mb-2" : ""'
.pr-3 )
v-switch.ml-3( v-divider.mt-3
v-model='strategy.selfRegistration' v-subheader.pl-0 Registration
label='Allow self-registration' .pr-3
color='primary' v-switch.ml-3(
hint='Allow any user successfully authorized by the strategy to access the wiki.' v-model='strategy.selfRegistration'
persistent-hint label='Allow self-registration'
) color='primary'
v-combobox.ml-3.mt-3( hint='Allow any user successfully authorized by the strategy to access the wiki.'
label='Limit to specific email domains' persistent-hint
v-model='strategy.domainWhitelist' )
prepend-icon='mail_outline' v-switch.ml-3(
outline v-if='strategy.useForm'
background-color='grey lighten-2' :disabled='!strategy.selfRegistration || true'
persistent-hint v-model='strategy.recaptcha'
small-chips label='Use reCAPTCHA by Google'
deletable-chips color='primary'
clearable hint='Protects against spam robots and malicious registrations.'
multiple persistent-hint
chips )
) v-combobox.ml-3.mt-3(
v-autocomplete.ml-3( label='Limit to specific email domains'
outline v-model='strategy.domainWhitelist'
background-color='grey lighten-2' prepend-icon='mail_outline'
:items='groups' outline
item-text='name' :disabled='!strategy.selfRegistration'
item-value='id' hint='A list of domains authorized to register. The user email address domain must match one of these to gain access.'
label='Assign to group' persistent-hint
v-model='strategy.autoEnrollGroups' small-chips
prepend-icon='people' deletable-chips
hint='Automatically assign new users to these groups.' clearable
small-chips multiple
persistent-hint chips
deletable-chips )
clearable v-autocomplete.mt-3.ml-3(
multiple outline
chips :disabled='!strategy.selfRegistration'
) :items='groups'
template(v-if='strategy.key === `local`') item-text='name'
v-divider.mt-3 item-value='id'
v-subheader.pl-0 Security label='Assign to group'
.pr-3 v-model='strategy.autoEnrollGroups'
v-switch.ml-3( prepend-icon='people'
:disabled='true' hint='Automatically assign new users to these groups.'
v-model='strategy.recaptcha' small-chips
label='Use reCAPTCHA by Google' persistent-hint
color='primary' deletable-chips
hint='Protects against spam robots and malicious registrations.' clearable
persistent-hint multiple
) chips
)
template(v-if='strategy.useForm')
v-divider.mt-3
v-subheader.pl-0 Security
v-switch.ml-3(
v-model='strategy.recaptcha'
:disabled='true'
label='Force all users to use Two-Factor Authentication (2FA)'
color='primary'
hint='Users will be required to setup 2FA the first time they login and cannot be disabled by the user.'
persistent-hint
)
v-card.mt-3.wiki-form.animated.fadeInUp.wait-p4s
v-toolbar(color='primary', dense, flat, dark)
.subheading Configuration Reference
v-card-text
.body-1 Some strategies may require some configuration values to be set on your provider.
v-alert.mt-3.radius-7(v-if='host.length < 8', color='red', outline, :value='true', icon='warning') You must set a valid #[strong Site URL] first! Click on #[strong General] in the left sidebar.
.pa-3.mt-3.radius-7.grey(v-else, :class='$vuetify.dark ? `darken-3-d5` : `lighten-3`')
.body-2 Allowed Web Origins
.body-1 {{host}}
v-divider.my-3
.body-2 Callback URL
.body-1 {{host}}/login/callback/{{strategy.key}}
v-divider.my-3
.body-2 Login URL
.body-1 {{host}}/login
v-divider.my-3
.body-2 Logout URL
.body-1 {{host}}
v-divider.my-3
.body-2 Token Endpoint Authentication Method
.body-1 HTTP-POST
</template> </template>
<script> <script>
@ -174,6 +202,7 @@ import _ from 'lodash'
import groupsQuery from 'gql/admin/auth/auth-query-groups.gql' import groupsQuery from 'gql/admin/auth/auth-query-groups.gql'
import strategiesQuery from 'gql/admin/auth/auth-query-strategies.gql' import strategiesQuery from 'gql/admin/auth/auth-query-strategies.gql'
import strategiesSaveMutation from 'gql/admin/auth/auth-mutation-save-strategies.gql' import strategiesSaveMutation from 'gql/admin/auth/auth-mutation-save-strategies.gql'
import hostQuery from 'gql/admin/auth/auth-query-host.gql'
export default { export default {
filters: { filters: {
@ -183,6 +212,9 @@ export default {
return { return {
groups: [], groups: [],
strategies: [], strategies: [],
selectedStrategy: '',
host: '',
strategy: {},
jwtAudience: 'urn:wiki.js', jwtAudience: 'urn:wiki.js',
jwtExpiration: '30m', jwtExpiration: '30m',
jwtRenewablePeriod: '14d' jwtRenewablePeriod: '14d'
@ -193,6 +225,14 @@ export default {
return _.filter(this.strategies, 'isEnabled') return _.filter(this.strategies, 'isEnabled')
} }
}, },
watch: {
selectedStrategy(newValue, oldValue) {
this.strategy = _.find(this.strategies, ['key', newValue]) || {}
},
strategies(newValue, oldValue) {
this.selectedStrategy = 'local'
}
},
methods: { methods: {
async refresh() { async refresh() {
await this.$apollo.queries.strategies.refetch() await this.$apollo.queries.strategies.refetch()
@ -238,7 +278,13 @@ export default {
strategies: { strategies: {
query: strategiesQuery, query: strategiesQuery,
fetchPolicy: 'network-only', fetchPolicy: 'network-only',
update: (data) => _.cloneDeep(data.authentication.strategies).map(str => ({...str, config: str.config.map(cfg => ({...cfg, value: JSON.parse(cfg.value)}))})), update: (data) => _.cloneDeep(data.authentication.strategies).map(str => ({
...str,
config: _.sortBy(str.config.map(cfg => ({
...cfg,
value: JSON.parse(cfg.value)
})), [t => t.value.order])
})),
watchLoading (isLoading) { watchLoading (isLoading) {
this.$store.commit(`loading${isLoading ? 'Start' : 'Stop'}`, 'admin-auth-refresh') this.$store.commit(`loading${isLoading ? 'Start' : 'Stop'}`, 'admin-auth-refresh')
} }
@ -250,6 +296,14 @@ export default {
watchLoading (isLoading) { watchLoading (isLoading) {
this.$store.commit(`loading${isLoading ? 'Start' : 'Stop'}`, 'admin-auth-groups-refresh') this.$store.commit(`loading${isLoading ? 'Start' : 'Stop'}`, 'admin-auth-groups-refresh')
} }
},
host: {
query: hostQuery,
fetchPolicy: 'network-only',
update: (data) => _.cloneDeep(data.site.config.host),
watchLoading (isLoading) {
this.$store.commit(`loading${isLoading ? 'Start' : 'Stop'}`, 'admin-auth-host-refresh')
}
} }
} }
} }

View File

@ -21,17 +21,19 @@
v-card.animated.fadeInUp v-card.animated.fadeInUp
v-toolbar(flat, color='primary', dark, dense) v-toolbar(flat, color='primary', dark, dense)
.subheading Search Engine .subheading Search Engine
v-card-text v-list.py-0(two-line, dense)
v-radio-group.my-0(v-model='selectedEngine') template(v-for='(eng, idx) in engines')
v-radio.my-1( v-list-tile(:key='eng.key', @click='selectedEngine = eng.key', :disabled='!eng.isAvailable')
v-for='(engine, n) in engines' v-list-tile-avatar
:key='engine.key' v-icon(color='grey', v-if='!eng.isAvailable') cancel
:label='engine.title' v-icon(color='primary', v-else-if='eng.key === selectedEngine') radio_button_checked
:value='engine.key' v-icon(color='grey', v-else) radio_button_unchecked
:disabled='!engine.isAvailable' v-list-tile-content
color='primary' v-list-tile-title.body-2(:class='!eng.isAvailable ? `grey--text` : (selectedEngine === eng.key ? `primary--text` : ``)') {{ eng.title }}
hide-details v-list-tile-sub-title.caption(:class='!eng.isAvailable ? `grey--text text--lighten-1` : (selectedEngine === eng.key ? `blue--text ` : ``)') {{ eng.description }}
) v-list-tile-avatar(v-if='selectedEngine === eng.key')
v-icon.animated.fadeInLeft(color='primary') arrow_forward_ios
v-divider(v-if='idx < engines.length - 1')
v-flex(lg9, xs12) v-flex(lg9, xs12)
v-card.wiki-form.animated.fadeInUp.wait-p2s v-card.wiki-form.animated.fadeInUp.wait-p2s

View File

@ -14,167 +14,175 @@
v-icon(left) check v-icon(left) check
span {{$t('common:actions.apply')}} span {{$t('common:actions.apply')}}
v-card.mt-3.animated.fadeInUp v-flex(lg3, xs12)
v-tabs(color='grey darken-2', fixed-tabs, slider-color='white', show-arrows, dark, v-model='currentTab') v-card.animated.fadeInUp
v-tab(key='settings'): v-icon settings v-toolbar(flat, color='primary', dark, dense)
v-tab(v-for='tgt in activeTargets', :key='tgt.key') {{ tgt.title }} .subheading Targets
v-list(two-line, dense).py-0
template(v-for='(tgt, idx) in targets')
v-list-tile(:key='tgt.key', @click='selectedTarget = tgt.key', :disabled='!tgt.isAvailable')
v-list-tile-avatar
v-icon(color='grey', v-if='!tgt.isAvailable') indeterminate_check_box
v-icon(color='primary', v-else-if='tgt.isEnabled', v-ripple, @click='tgt.key !== `local` && (tgt.isEnabled = false)') check_box
v-icon(color='grey', v-else, v-ripple, @click='tgt.isEnabled = true') check_box_outline_blank
v-list-tile-content
v-list-tile-title.body-2(:class='!tgt.isAvailable ? `grey--text` : (selectedTarget === tgt.key ? `primary--text` : ``)') {{ tgt.title }}
v-list-tile-sub-title.caption(:class='!tgt.isAvailable ? `grey--text text--lighten-1` : (selectedTarget === tgt.key ? `blue--text ` : ``)') {{ tgt.description }}
v-list-tile-avatar(v-if='selectedTarget === tgt.key')
v-icon.animated.fadeInLeft(color='primary') arrow_forward_ios
v-divider(v-if='idx < targets.length - 1')
v-tab-item(key='settings', :transition='false', :reverse-transition='false') v-card.mt-3.animated.fadeInUp.wait-p2s
v-container.pa-3(fluid, grid-list-md) v-toolbar(flat, :color='$vuetify.dark ? `grey darken-3-l5` : `grey darken-3`', dark, dense)
v-layout(row, wrap) .subheading Status
v-flex(xs12, md6) v-spacer
.body-2.grey--text.text--darken-1 Select which storage targets to enable: looping-rhombuses-spinner(
.caption.grey--text.pb-2 Some storage targets require additional configuration in their dedicated tab (when selected). :animation-duration='5000'
v-form :rhombus-size='10'
v-checkbox.my-0( color='#FFF'
:disabled='!tgt.isAvailable' )
v-for='tgt in targets' v-list.py-0(two-line, dense)
v-model='tgt.isEnabled' template(v-for='(tgt, n) in status')
:key='tgt.key' v-list-tile(:key='tgt.key')
:label='tgt.title' template(v-if='tgt.status === `pending`')
color='primary' v-list-tile-avatar(color='purple')
hide-details v-icon(color='white') schedule
) v-list-tile-content
v-flex(xs12, md6) v-list-tile-title.body-2 {{tgt.title}}
.pa-3.grey.radius-7(:class='$vuetify.dark ? "darken-4" : "lighten-5"') v-list-tile-sub-title.purple--text.caption {{tgt.status}}
v-layout.pa-2(row, justify-space-between) v-list-tile-action
.body-2.grey--text.text--darken-1 Status v-progress-circular(indeterminate, :size='20', :width='2', color='purple')
.d-flex template(v-else-if='tgt.status === `operational`')
looping-rhombuses-spinner.mt-1( v-list-tile-avatar(color='green')
:animation-duration='5000' v-icon(color='white') check_circle
:rhombus-size='10' v-list-tile-content
color='#BBB' v-list-tile-title.body-2 {{tgt.title}}
) v-list-tile-sub-title.green--text.caption Last synchronization {{tgt.lastAttempt | moment('from') }}
.caption.ml-3.grey--text This panel refreshes automatically. template(v-else)
v-divider v-list-tile-avatar(color='red')
v-toolbar.mt-2.radius-7( v-icon(color='white') highlight_off
v-for='(tgt, n) in status' v-list-tile-content
:key='tgt.key' v-list-tile-title.body-2 {{tgt.title}}
dense v-list-tile-sub-title.red--text.caption Last attempt was {{tgt.lastAttempt | moment('from') }}
:color='getStatusColor(tgt.status)' v-list-tile-action
dark v-menu
flat v-btn(slot='activator', icon)
:extended='tgt.status !== `pending`', v-icon(color='red') info
:extension-height='tgt.status === `error` ? 100 : 70' v-card(width='450')
) v-toolbar(flat, color='red', dark, dense) Error Message
.pa-3.red.darken-2.radius-7(v-if='tgt.status === `error`', slot='extension') {{tgt.message}} v-card-text {{tgt.message}}
v-toolbar.radius-7(
color='green darken-2'
v-else-if='tgt.status !== `pending`'
slot='extension'
flat
dense
)
span Last synchronization {{tgt.lastAttempt | moment('from') }}
.body-2 {{tgt.title}}
v-spacer
.body-1 {{tgt.status}}
v-alert.mt-3.radius-7(v-if='status.length < 1', outline, :value='true', color='indigo') You don't have any active storage target.
v-tab-item(v-for='(tgt, n) in activeTargets', :key='tgt.key', :transition='false', :reverse-transition='false') v-divider(v-if='n < status.length - 1')
v-card.wiki-form.pa-3(flat, tile) v-list-tile(v-if='status.length < 1')
v-form em You don't have any active storage target.
.targetlogo
img(:src='tgt.logo', :alt='tgt.title')
v-subheader.pl-0 {{tgt.title}}
.caption {{tgt.description}}
.caption: a(:href='tgt.website') {{tgt.website}}
v-divider.mt-3
v-subheader.pl-0 Target Configuration
.body-1.ml-3(v-if='!tgt.config || tgt.config.length < 1') This storage target has no configuration options you can modify.
template(v-else, v-for='cfg in tgt.config')
v-select(
v-if='cfg.value.type === "string" && cfg.value.enum'
outline
background-color='grey lighten-2'
:items='cfg.value.enum'
:key='cfg.key'
:label='cfg.value.title'
v-model='cfg.value.value'
prepend-icon='settings_applications'
:hint='cfg.value.hint ? cfg.value.hint : ""'
persistent-hint
:class='cfg.value.hint ? "mb-2" : ""'
)
v-switch.mb-3(
v-else-if='cfg.value.type === "boolean"'
:key='cfg.key'
:label='cfg.value.title'
v-model='cfg.value.value'
color='primary'
prepend-icon='settings_applications'
:hint='cfg.value.hint ? cfg.value.hint : ""'
persistent-hint
)
v-text-field(
v-else
outline
background-color='grey lighten-2'
:key='cfg.key'
:label='cfg.value.title'
v-model='cfg.value.value'
prepend-icon='settings_applications'
:hint='cfg.value.hint ? cfg.value.hint : ""'
persistent-hint
:class='cfg.value.hint ? "mb-2" : ""'
)
v-divider.mt-3
v-subheader.pl-0 Sync Direction
.body-1.ml-3 Choose how content synchronization is handled for this storage target.
.pr-3.pt-3
v-radio-group.ml-3.py-0(v-model='tgt.mode')
v-radio(
label='Bi-directional'
color='primary'
value='sync'
:disabled='tgt.supportedModes.indexOf(`sync`) < 0'
)
v-radio(
label='Push to target'
color='primary'
value='push'
:disabled='tgt.supportedModes.indexOf(`push`) < 0'
)
v-radio(
label='Pull from target'
color='primary'
value='pull'
:disabled='tgt.supportedModes.indexOf(`pull`) < 0'
)
.body-1.ml-3
strong Bi-directional #[em.red--text.text--lighten-2(v-if='tgt.supportedModes.indexOf(`sync`) < 0') Unsupported]
.pb-3 In bi-directional mode, content is first pulled from the storage target. Any newer content overwrites local content. New content since last sync is then pushed to the storage target, overwriting any content on target if present.
strong Push to target #[em.red--text.text--lighten-2(v-if='tgt.supportedModes.indexOf(`push`) < 0') Unsupported]
.pb-3 Content is always pushed to the storage target, overwriting any existing content. This is safest choice for backup scenarios.
strong Pull from target #[em.red--text.text--lighten-2(v-if='tgt.supportedModes.indexOf(`pull`) < 0') Unsupported]
.pb-3 Content is always pulled from the storage target, overwriting any local content which already exists. This choice is usually reserved for single-use content import. Caution with this option as any local content will always be overwritten!
template(v-if='tgt.hasSchedule') v-flex(xs12, lg9)
v-divider.mt-3 v-card.wiki-form.animated.fadeInUp.wait-p2s
v-subheader.pl-0 Sync Schedule v-toolbar(color='primary', dense, flat, dark)
.body-1.ml-3 For performance reasons, this storage target synchronize changes on an interval-based schedule, instead of on every change. Define at which interval should the synchronization occur. .subheading {{target.title}}
.pa-3 v-card-text
duration-picker(v-model='tgt.syncInterval') v-form
.caption.mt-3 Currently set to every #[strong {{getDefaultSchedule(tgt.syncInterval)}}]. .targetlogo
.caption The default is every #[strong {{getDefaultSchedule(tgt.syncIntervalDefault)}}]. img(:src='target.logo', :alt='target.title')
v-subheader.pl-0 {{target.title}}
.caption {{target.description}}
.caption: a(:href='target.website') {{target.website}}
v-divider.mt-3
v-subheader.pl-0 Target Configuration
.body-1.ml-3(v-if='!target.config || target.config.length < 1') This storage target has no configuration options you can modify.
template(v-else, v-for='cfg in target.config')
v-select(
v-if='cfg.value.type === "string" && cfg.value.enum'
outline
background-color='grey lighten-2'
:items='cfg.value.enum'
:key='cfg.key'
:label='cfg.value.title'
v-model='cfg.value.value'
prepend-icon='settings_applications'
:hint='cfg.value.hint ? cfg.value.hint : ""'
persistent-hint
:class='cfg.value.hint ? "mb-2" : ""'
)
v-switch.mb-3(
v-else-if='cfg.value.type === "boolean"'
:key='cfg.key'
:label='cfg.value.title'
v-model='cfg.value.value'
color='primary'
prepend-icon='settings_applications'
:hint='cfg.value.hint ? cfg.value.hint : ""'
persistent-hint
)
v-text-field(
v-else
outline
background-color='grey lighten-2'
:key='cfg.key'
:label='cfg.value.title'
v-model='cfg.value.value'
prepend-icon='settings_applications'
:hint='cfg.value.hint ? cfg.value.hint : ""'
persistent-hint
:class='cfg.value.hint ? "mb-2" : ""'
)
v-divider.mt-3
v-subheader.pl-0 Sync Direction
.body-1.ml-3 Choose how content synchronization is handled for this storage target.
.pr-3.pt-3
v-radio-group.ml-3.py-0(v-model='target.mode')
v-radio(
label='Bi-directional'
color='primary'
value='sync'
:disabled='target.supportedModes.indexOf(`sync`) < 0'
)
v-radio(
label='Push to target'
color='primary'
value='push'
:disabled='target.supportedModes.indexOf(`push`) < 0'
)
v-radio(
label='Pull from target'
color='primary'
value='pull'
:disabled='target.supportedModes.indexOf(`pull`) < 0'
)
.body-1.ml-3
strong Bi-directional #[em.red--text.text--lighten-2(v-if='target.supportedModes.indexOf(`sync`) < 0') Unsupported]
.pb-3 In bi-directional mode, content is first pulled from the storage target. Any newer content overwrites local content. New content since last sync is then pushed to the storage target, overwriting any content on target if present.
strong Push to target #[em.red--text.text--lighten-2(v-if='target.supportedModes.indexOf(`push`) < 0') Unsupported]
.pb-3 Content is always pushed to the storage target, overwriting any existing content. This is safest choice for backup scenarios.
strong Pull from target #[em.red--text.text--lighten-2(v-if='target.supportedModes.indexOf(`pull`) < 0') Unsupported]
.pb-3 Content is always pulled from the storage target, overwriting any local content which already exists. This choice is usually reserved for single-use content import. Caution with this option as any local content will always be overwritten!
template(v-if='tgt.actions && tgt.actions.length > 0') template(v-if='target.hasSchedule')
v-divider.mt-3 v-divider.mt-3
v-subheader.pl-0 Actions v-subheader.pl-0 Sync Schedule
v-container.pt-0(grid-list-xl, fluid) .body-1.ml-3 For performance reasons, this storage target synchronize changes on an interval-based schedule, instead of on every change. Define at which interval should the synchronization occur.
v-layout(row, wrap, fill-height) .pa-3
v-flex(xs12, lg6, xl4, v-for='act of tgt.actions') duration-picker(v-model='target.syncInterval')
v-card.radius-7.grey(flat, :class='$vuetify.dark ? `darken-3-d5` : `lighten-3`', height='100%') .caption.mt-3 Currently set to every #[strong {{getDefaultSchedule(target.syncInterval)}}].
v-card-text .caption The default is every #[strong {{getDefaultSchedule(target.syncIntervalDefault)}}].
.subheading(v-html='act.label')
.body-1.mt-2(v-html='act.hint') template(v-if='target.actions && target.actions.length > 0')
v-btn.mx-0.mt-3( v-divider.mt-3
@click='executeAction(tgt.key, act.handler)' v-subheader.pl-0 Actions
outline v-container.pt-0(grid-list-xl, fluid)
:color='$vuetify.dark ? `blue` : `primary`' v-layout(row, wrap, fill-height)
:disabled='runningAction' v-flex(xs12, lg6, xl4, v-for='act of target.actions', :key='act.handler')
:loading='runningActionHandler === act.handler' v-card.radius-7.grey(flat, :class='$vuetify.dark ? `darken-3-d5` : `lighten-3`', height='100%')
) Run v-card-text
.subheading(v-html='act.label')
.body-1.mt-2(v-html='act.hint')
v-btn.mx-0.mt-3(
@click='executeAction(target.key, act.handler)'
outline
:color='$vuetify.dark ? `blue` : `primary`'
:disabled='runningAction'
:loading='runningActionHandler === act.handler'
) Run
</template> </template>
@ -205,7 +213,8 @@ export default {
return { return {
runningAction: false, runningAction: false,
runningActionHandler: '', runningActionHandler: '',
currentTab: 0, selectedTarget: '',
target: {},
targets: [], targets: [],
status: [] status: []
} }
@ -215,6 +224,14 @@ export default {
return _.filter(this.targets, 'isEnabled') return _.filter(this.targets, 'isEnabled')
} }
}, },
watch: {
selectedTarget(newValue, oldValue) {
this.target = _.find(this.targets, ['key', newValue]) || {}
},
targets(newValue, oldValue) {
this.selectedTarget = _.get(_.find(this.targets, ['isEnabled', true]), 'key', 'disk')
}
},
methods: { methods: {
async refresh() { async refresh() {
await this.$apollo.queries.targets.refetch() await this.$apollo.queries.targets.refetch()
@ -238,7 +255,6 @@ export default {
])).map(str => ({...str, config: str.config.map(cfg => ({...cfg, value: JSON.stringify({ v: cfg.value.value })}))})) ])).map(str => ({...str, config: str.config.map(cfg => ({...cfg, value: JSON.stringify({ v: cfg.value.value })}))}))
} }
}) })
this.currentTab = 0
this.$store.commit('showNotification', { this.$store.commit('showNotification', {
message: 'Storage configuration saved successfully.', message: 'Storage configuration saved successfully.',
style: 'success', style: 'success',

View File

@ -9,7 +9,7 @@
v-toolbar.radius-7(color='teal lighten-5', dense, flat, height='44') v-toolbar.radius-7(color='teal lighten-5', dense, flat, height='44')
.body-2.teal--text Images .body-2.teal--text Images
v-btn.ml-3.my-0.radius-7(outline, large, color='teal', disabled) v-btn.ml-3.my-0.radius-7(outline, large, color='teal', disabled)
v-icon(left) keyboard_backspace v-icon(left) keyboard_arrow_up
span Parent Folder span Parent Folder
v-btn.my-0.radius-7(outline, large, color='teal') v-btn.my-0.radius-7(outline, large, color='teal')
v-icon(left) add v-icon(left) add
@ -48,14 +48,15 @@
ref='pond' ref='pond'
label-idle='Browse or Drop files here...' label-idle='Browse or Drop files here...'
allow-multiple='true' allow-multiple='true'
accepted-file-types='image/jpeg, image/png' accepted-file-types='image/jpeg, image/png, image/gif, image/svg'
:files='files' :files='files'
max-files='10'
) )
v-divider v-divider
v-card-actions.pa-3 v-card-actions.pa-3
.caption.grey--text.text-darken-2 Max 20 files, 5 MB each .caption.grey--text.text-darken-2 Max 10 files, 5 MB each
v-spacer v-spacer
v-btn(color='teal', dark) Upload v-btn(color='teal', dark, @click='upload') Upload
v-card.mt-3.radius-7.animated.fadeInRight.wait-p4s(light) v-card.mt-3.radius-7.animated.fadeInRight.wait-p4s(light)
v-card-text.pb-0 v-card-text.pb-0
@ -96,11 +97,11 @@ import { sync } from 'vuex-pathify'
import vueFilePond from 'vue-filepond' import vueFilePond from 'vue-filepond'
import 'filepond/dist/filepond.min.css' import 'filepond/dist/filepond.min.css'
import 'filepond-plugin-image-preview/dist/filepond-plugin-image-preview.min.css'
import FilePondPluginFileValidateType from 'filepond-plugin-file-validate-type' import FilePondPluginFileValidateType from 'filepond-plugin-file-validate-type'
import FilePondPluginImagePreview from 'filepond-plugin-image-preview'
const FilePond = vueFilePond(FilePondPluginFileValidateType, FilePondPluginImagePreview) import uploadFileMutation from 'gql/editor/upload.gql'
const FilePond = vueFilePond(FilePondPluginFileValidateType)
export default { export default {
components: { components: {
@ -135,6 +136,21 @@ export default {
methods: { methods: {
insert () { insert () {
this.activeModal = '' this.activeModal = ''
},
async upload () {
const files = this.$refs.pond.getFiles()
for (let fl of files) {
const resp = await this.$apollo.mutate({
mutation: uploadFileMutation,
variables: {
data: fl.file
},
context: {
hasUpload: true
}
})
console.info(resp)
}
} }
} }
} }

View File

@ -0,0 +1,7 @@
{
site {
config {
host
}
}
}

View File

@ -5,6 +5,7 @@ query {
key key
title title
description description
isAvailable
useForm useForm
logo logo
website website

View File

@ -0,0 +1,10 @@
mutation ($file: Upload!) {
assets {
upload(data:$file) {
responseResult {
succeeded
message
}
}
}
}

View File

@ -76,6 +76,7 @@
"graphql-rate-limit-directive": "0.1.0", "graphql-rate-limit-directive": "0.1.0",
"graphql-subscriptions": "1.0.0", "graphql-subscriptions": "1.0.0",
"graphql-tools": "4.0.4", "graphql-tools": "4.0.4",
"graphql-upload": "8.0.5",
"highlight.js": "9.14.2", "highlight.js": "9.14.2",
"i18next": "14.0.1", "i18next": "14.0.1",
"i18next-express-middleware": "1.7.1", "i18next-express-middleware": "1.7.1",
@ -192,6 +193,7 @@
"apollo-link-http": "1.5.11", "apollo-link-http": "1.5.11",
"apollo-link-persisted-queries": "0.2.2", "apollo-link-persisted-queries": "0.2.2",
"apollo-link-ws": "1.0.14", "apollo-link-ws": "1.0.14",
"apollo-upload-client": "10.0.0",
"apollo-utilities": "1.1.2", "apollo-utilities": "1.1.2",
"autoprefixer": "9.4.7", "autoprefixer": "9.4.7",
"babel-eslint": "10.0.1", "babel-eslint": "10.0.1",
@ -221,7 +223,6 @@
"file-loader": "3.0.1", "file-loader": "3.0.1",
"filepond": "4.2.0", "filepond": "4.2.0",
"filepond-plugin-file-validate-type": "1.2.2", "filepond-plugin-file-validate-type": "1.2.2",
"filepond-plugin-image-preview": "4.0.3",
"filesize.js": "1.0.2", "filesize.js": "1.0.2",
"grapesjs": "0.14.52", "grapesjs": "0.14.52",
"graphiql": "0.12.0", "graphiql": "0.12.0",

View File

@ -0,0 +1,16 @@
exports.up = knex => {
const dbCompat = {
charset: (WIKI.config.db.type === `mysql` || WIKI.config.db.type === `mariadb`)
}
return knex.schema
.createTable('assetData', table => {
if (dbCompat.charset) { table.charset('utf8mb4') }
table.integer('id').primary()
table.binary('data').notNullable()
})
}
exports.down = knex => {
return knex.schema
.dropTableIfExists('assetData')
}

View File

@ -0,0 +1,16 @@
exports.up = knex => {
const dbCompat = {
charset: (WIKI.config.db.type === `mysql` || WIKI.config.db.type === `mariadb`)
}
return knex.schema
.createTable('assetData', table => {
if (dbCompat.charset) { table.charset('utf8mb4') }
table.integer('id').primary()
table.binary('data').notNullable()
})
}
exports.down = knex => {
return knex.schema
.dropTableIfExists('assetData')
}

View File

@ -7,6 +7,7 @@ const PubSub = require('graphql-subscriptions').PubSub
const { LEVEL, MESSAGE } = require('triple-beam') const { LEVEL, MESSAGE } = require('triple-beam')
const Transport = require('winston-transport') const Transport = require('winston-transport')
const { createRateLimitTypeDef } = require('graphql-rate-limit-directive') const { createRateLimitTypeDef } = require('graphql-rate-limit-directive')
const { GraphQLUpload } = require('graphql-upload')
/* global WIKI */ /* global WIKI */
@ -26,7 +27,9 @@ schemas.forEach(schema => {
// Resolvers // Resolvers
let resolvers = {} let resolvers = {
Upload: GraphQLUpload
}
const resolversObj = _.values(autoload(path.join(WIKI.SERVERPATH, 'graph/resolvers'))) const resolversObj = _.values(autoload(path.join(WIKI.SERVERPATH, 'graph/resolvers')))
resolversObj.forEach(resolver => { resolversObj.forEach(resolver => {
_.merge(resolvers, resolver) _.merge(resolvers, resolver)

View File

@ -0,0 +1,45 @@
# ===============================================
# ASSETS
# ===============================================
extend type Query {
assets: AssetQuery
}
extend type Mutation {
assets: AssetMutation
}
# -----------------------------------------------
# QUERIES
# -----------------------------------------------
type AssetQuery {
list(
root: String
kind: [AssetKind]
): [AssetItem]
}
# -----------------------------------------------
# MUTATIONS
# -----------------------------------------------
type AssetMutation {
upload(
data: Upload!
): DefaultResponse
}
# -----------------------------------------------
# TYPES
# -----------------------------------------------
type AssetItem {
id: Int!
}
enum AssetKind {
IMAGE
BINARY
}

View File

@ -58,6 +58,7 @@ type AuthenticationStrategy {
props: [String] props: [String]
title: String! title: String!
description: String description: String
isAvailable: Boolean
useForm: Boolean! useForm: Boolean!
logo: String logo: String
color: String color: String

View File

@ -11,7 +11,6 @@ const https = require('https')
const path = require('path') const path = require('path')
const _ = require('lodash') const _ = require('lodash')
const { ApolloServer } = require('apollo-server-express') const { ApolloServer } = require('apollo-server-express')
// const oauth2orize = require('oauth2orize')
/* global WIKI */ /* global WIKI */
@ -61,12 +60,6 @@ module.exports = async () => {
maxAge: '7d' maxAge: '7d'
})) }))
// ----------------------------------------
// OAuth2 Server
// ----------------------------------------
// const OAuth2Server = oauth2orize.createServer()
// ---------------------------------------- // ----------------------------------------
// Passport Authentication // Passport Authentication
// ---------------------------------------- // ----------------------------------------
@ -137,6 +130,7 @@ module.exports = async () => {
path: '/graphql-subscriptions' path: '/graphql-subscriptions'
} }
}) })
app.use('/graphql', mw.upload)
apolloServer.applyMiddleware({ app }) apolloServer.applyMiddleware({ app })
// ---------------------------------------- // ----------------------------------------

View File

@ -0,0 +1,8 @@
const { graphqlUploadExpress } = require('graphql-upload')
/* global WIKI */
/**
* GraphQL File Upload Middleware
*/
module.exports = graphqlUploadExpress({ maxFileSize: 5000000, maxFiles: 20 })

View File

@ -5,8 +5,21 @@ author: requarks.io
logo: https://static.requarks.io/logo/auth0.svg logo: https://static.requarks.io/logo/auth0.svg
color: deep-orange color: deep-orange
website: https://auth0.com/ website: https://auth0.com/
isAvailable: true
useForm: false useForm: false
props: props:
domain: String domain:
clientId: String type: String
clientSecret: String title: Domain
hint: Your Auth0 domain (e.g. something.auth0.com)
order: 1
clientId:
type: String
title: Client ID
hint: Application Client ID
order: 2
clientSecret:
type: String
title: Client Secret
hint: Application Client Secret
order: 3

View File

@ -5,5 +5,6 @@ author: requarks.io
logo: https://static.requarks.io/logo/wikijs.svg logo: https://static.requarks.io/logo/wikijs.svg
color: yellow darken-3 color: yellow darken-3
website: https://wiki.js.org website: https://wiki.js.org
isAvailable: true
useForm: true useForm: true
props: {} props: {}