Commit 6fe49309 authored by Nick's avatar Nick

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

parent 59683318
...@@ -5,11 +5,10 @@ import VueRouter from 'vue-router' ...@@ -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') ...@@ -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([ ...@@ -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([ ...@@ -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([ ...@@ -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({ ...@@ -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')
}) })
......
...@@ -49,7 +49,7 @@ ...@@ -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')
......
...@@ -6,7 +6,7 @@ ...@@ -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,35 +14,31 @@ ...@@ -14,35 +14,31 @@
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-form
//- TODO - Prevent crash on unfinished strategies
v-checkbox.my-0(
v-for='strategy in strategies'
v-model='strategy.isEnabled'
:key='strategy.key'
:label='strategy.title'
color='primary'
:disabled='strategy.key === `local` || true'
hide-details
)
v-flex(xs12, md6)
.pa-3.grey.radius-7(:class='$vuetify.dark ? "darken-4" : "lighten-5"')
.body-2.grey--text.text--darken-1 Advanced Settings
v-text-field.mt-3.md2(
v-model='jwtAudience' v-model='jwtAudience'
outline outline
background-color='grey lighten-2'
prepend-icon='account_balance' prepend-icon='account_balance'
label='JWT Audience' label='JWT Audience'
hint='Audience URN used in JWT issued upon login. Usually your domain name. (e.g. urn:your.domain.com)' hint='Audience URN used in JWT issued upon login. Usually your domain name. (e.g. urn:your.domain.com)'
...@@ -51,7 +47,6 @@ ...@@ -51,7 +47,6 @@
v-text-field.mt-3.md2( v-text-field.mt-3.md2(
v-model='jwtExpiration' v-model='jwtExpiration'
outline outline
background-color='grey lighten-2'
prepend-icon='schedule' prepend-icon='schedule'
label='Token Expiration' label='Token Expiration'
hint='The expiration period of a token until it must be renewed. (default: 30m)' hint='The expiration period of a token until it must be renewed. (default: 30m)'
...@@ -60,24 +55,26 @@ ...@@ -60,24 +55,26 @@
v-text-field.mt-3.md2( v-text-field.mt-3.md2(
v-model='jwtRenewablePeriod' v-model='jwtRenewablePeriod'
outline outline
background-color='grey lighten-2'
prepend-icon='update' prepend-icon='update'
label='Token Renewal Period' label='Token Renewal Period'
hint='The maximum period a token can be renewed when expired. (default: 14d)' hint='The maximum period a token can be renewed when expired. (default: 14d)'
persistent-hint 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-card.wiki-form.animated.fadeInUp.wait-p2s
v-toolbar(color='primary', dense, flat, dark)
.subheading {{strategy.title}}
v-card-text
v-form v-form
.authlogo .authlogo
img(:src='strategy.logo', :alt='strategy.title') img(:src='strategy.logo', :alt='strategy.title')
v-subheader.pl-0 {{strategy.title}} .caption.pt-3 {{strategy.description}}
.caption {{strategy.description}} .caption.pb-3: a(:href='strategy.website') {{strategy.website}}
.caption: a(:href='strategy.website') {{strategy.website}}
v-divider.mt-3 v-divider.mt-3
v-subheader.pl-0 Strategy Configuration v-subheader.pl-0 Strategy Configuration
.body-1.ml-3(v-if='!strategy.config || strategy.config.length < 1') This strategy has no configuration options you can modify. .body-1.ml-3(v-if='!strategy.config || strategy.config.length < 1'): em This strategy has no configuration options you can modify.
template(v-else, v-for='cfg in strategy.config') template(v-else, v-for='cfg in strategy.config')
v-select( v-select(
v-if='cfg.value.type === "string" && cfg.value.enum' v-if='cfg.value.type === "string" && cfg.value.enum'
...@@ -124,12 +121,22 @@ ...@@ -124,12 +121,22 @@
hint='Allow any user successfully authorized by the strategy to access the wiki.' hint='Allow any user successfully authorized by the strategy to access the wiki.'
persistent-hint persistent-hint
) )
v-switch.ml-3(
v-if='strategy.useForm'
:disabled='!strategy.selfRegistration || true'
v-model='strategy.recaptcha'
label='Use reCAPTCHA by Google'
color='primary'
hint='Protects against spam robots and malicious registrations.'
persistent-hint
)
v-combobox.ml-3.mt-3( v-combobox.ml-3.mt-3(
label='Limit to specific email domains' label='Limit to specific email domains'
v-model='strategy.domainWhitelist' v-model='strategy.domainWhitelist'
prepend-icon='mail_outline' prepend-icon='mail_outline'
outline outline
background-color='grey lighten-2' :disabled='!strategy.selfRegistration'
hint='A list of domains authorized to register. The user email address domain must match one of these to gain access.'
persistent-hint persistent-hint
small-chips small-chips
deletable-chips deletable-chips
...@@ -137,9 +144,9 @@ ...@@ -137,9 +144,9 @@
multiple multiple
chips chips
) )
v-autocomplete.ml-3( v-autocomplete.mt-3.ml-3(
outline outline
background-color='grey lighten-2' :disabled='!strategy.selfRegistration'
:items='groups' :items='groups'
item-text='name' item-text='name'
item-value='id' item-value='id'
...@@ -154,18 +161,39 @@ ...@@ -154,18 +161,39 @@
multiple multiple
chips chips
) )
template(v-if='strategy.key === `local`') template(v-if='strategy.useForm')
v-divider.mt-3 v-divider.mt-3
v-subheader.pl-0 Security v-subheader.pl-0 Security
.pr-3
v-switch.ml-3( v-switch.ml-3(
:disabled='true'
v-model='strategy.recaptcha' v-model='strategy.recaptcha'
label='Use reCAPTCHA by Google' :disabled='true'
label='Force all users to use Two-Factor Authentication (2FA)'
color='primary' color='primary'
hint='Protects against spam robots and malicious registrations.' hint='Users will be required to setup 2FA the first time they login and cannot be disabled by the user.'
persistent-hint 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' ...@@ -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 { ...@@ -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 { ...@@ -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 { ...@@ -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 { ...@@ -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')
}
} }
} }
} }
......
...@@ -21,17 +21,19 @@ ...@@ -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
......
...@@ -14,75 +14,83 @@ ...@@ -14,75 +14,83 @@
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).
v-form
v-checkbox.my-0(
:disabled='!tgt.isAvailable'
v-for='tgt in targets'
v-model='tgt.isEnabled'
:key='tgt.key'
:label='tgt.title'
color='primary'
hide-details
)
v-flex(xs12, md6)
.pa-3.grey.radius-7(:class='$vuetify.dark ? "darken-4" : "lighten-5"')
v-layout.pa-2(row, justify-space-between)
.body-2.grey--text.text--darken-1 Status
.d-flex
looping-rhombuses-spinner.mt-1(
:animation-duration='5000' :animation-duration='5000'
:rhombus-size='10' :rhombus-size='10'
color='#BBB' color='#FFF'
)
.caption.ml-3.grey--text This panel refreshes automatically.
v-divider
v-toolbar.mt-2.radius-7(
v-for='(tgt, n) in status'
:key='tgt.key'
dense
:color='getStatusColor(tgt.status)'
dark
flat
:extended='tgt.status !== `pending`',
:extension-height='tgt.status === `error` ? 100 : 70'
)
.pa-3.red.darken-2.radius-7(v-if='tgt.status === `error`', slot='extension') {{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') }} v-list.py-0(two-line, dense)
.body-2 {{tgt.title}} template(v-for='(tgt, n) in status')
v-spacer v-list-tile(:key='tgt.key')
.body-1 {{tgt.status}} template(v-if='tgt.status === `pending`')
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-list-tile-avatar(color='purple')
v-icon(color='white') schedule
v-list-tile-content
v-list-tile-title.body-2 {{tgt.title}}
v-list-tile-sub-title.purple--text.caption {{tgt.status}}
v-list-tile-action
v-progress-circular(indeterminate, :size='20', :width='2', color='purple')
template(v-else-if='tgt.status === `operational`')
v-list-tile-avatar(color='green')
v-icon(color='white') check_circle
v-list-tile-content
v-list-tile-title.body-2 {{tgt.title}}
v-list-tile-sub-title.green--text.caption Last synchronization {{tgt.lastAttempt | moment('from') }}
template(v-else)
v-list-tile-avatar(color='red')
v-icon(color='white') highlight_off
v-list-tile-content
v-list-tile-title.body-2 {{tgt.title}}
v-list-tile-sub-title.red--text.caption Last attempt was {{tgt.lastAttempt | moment('from') }}
v-list-tile-action
v-menu
v-btn(slot='activator', icon)
v-icon(color='red') info
v-card(width='450')
v-toolbar(flat, color='red', dark, dense) Error Message
v-card-text {{tgt.message}}
v-divider(v-if='n < status.length - 1')
v-list-tile(v-if='status.length < 1')
em 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-flex(xs12, lg9)
v-card.wiki-form.pa-3(flat, tile) v-card.wiki-form.animated.fadeInUp.wait-p2s
v-toolbar(color='primary', dense, flat, dark)
.subheading {{target.title}}
v-card-text
v-form v-form
.targetlogo .targetlogo
img(:src='tgt.logo', :alt='tgt.title') img(:src='target.logo', :alt='target.title')
v-subheader.pl-0 {{tgt.title}} v-subheader.pl-0 {{target.title}}
.caption {{tgt.description}} .caption {{target.description}}
.caption: a(:href='tgt.website') {{tgt.website}} .caption: a(:href='target.website') {{target.website}}
v-divider.mt-3 v-divider.mt-3
v-subheader.pl-0 Target Configuration 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. .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 tgt.config') template(v-else, v-for='cfg in target.config')
v-select( v-select(
v-if='cfg.value.type === "string" && cfg.value.enum' v-if='cfg.value.type === "string" && cfg.value.enum'
outline outline
...@@ -122,54 +130,54 @@ ...@@ -122,54 +130,54 @@
v-subheader.pl-0 Sync Direction v-subheader.pl-0 Sync Direction
.body-1.ml-3 Choose how content synchronization is handled for this storage target. .body-1.ml-3 Choose how content synchronization is handled for this storage target.
.pr-3.pt-3 .pr-3.pt-3
v-radio-group.ml-3.py-0(v-model='tgt.mode') v-radio-group.ml-3.py-0(v-model='target.mode')
v-radio( v-radio(
label='Bi-directional' label='Bi-directional'
color='primary' color='primary'
value='sync' value='sync'
:disabled='tgt.supportedModes.indexOf(`sync`) < 0' :disabled='target.supportedModes.indexOf(`sync`) < 0'
) )
v-radio( v-radio(
label='Push to target' label='Push to target'
color='primary' color='primary'
value='push' value='push'
:disabled='tgt.supportedModes.indexOf(`push`) < 0' :disabled='target.supportedModes.indexOf(`push`) < 0'
) )
v-radio( v-radio(
label='Pull from target' label='Pull from target'
color='primary' color='primary'
value='pull' value='pull'
:disabled='tgt.supportedModes.indexOf(`pull`) < 0' :disabled='target.supportedModes.indexOf(`pull`) < 0'
) )
.body-1.ml-3 .body-1.ml-3
strong Bi-directional #[em.red--text.text--lighten-2(v-if='tgt.supportedModes.indexOf(`sync`) < 0') Unsupported] 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. .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] 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. .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] 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! .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') template(v-if='target.hasSchedule')
v-divider.mt-3 v-divider.mt-3
v-subheader.pl-0 Sync Schedule v-subheader.pl-0 Sync Schedule
.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. .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.
.pa-3 .pa-3
duration-picker(v-model='tgt.syncInterval') duration-picker(v-model='target.syncInterval')
.caption.mt-3 Currently set to every #[strong {{getDefaultSchedule(tgt.syncInterval)}}]. .caption.mt-3 Currently set to every #[strong {{getDefaultSchedule(target.syncInterval)}}].
.caption The default is every #[strong {{getDefaultSchedule(tgt.syncIntervalDefault)}}]. .caption The default is every #[strong {{getDefaultSchedule(target.syncIntervalDefault)}}].
template(v-if='tgt.actions && tgt.actions.length > 0') template(v-if='target.actions && target.actions.length > 0')
v-divider.mt-3 v-divider.mt-3
v-subheader.pl-0 Actions v-subheader.pl-0 Actions
v-container.pt-0(grid-list-xl, fluid) v-container.pt-0(grid-list-xl, fluid)
v-layout(row, wrap, fill-height) v-layout(row, wrap, fill-height)
v-flex(xs12, lg6, xl4, v-for='act of tgt.actions') v-flex(xs12, lg6, xl4, v-for='act of target.actions', :key='act.handler')
v-card.radius-7.grey(flat, :class='$vuetify.dark ? `darken-3-d5` : `lighten-3`', height='100%') v-card.radius-7.grey(flat, :class='$vuetify.dark ? `darken-3-d5` : `lighten-3`', height='100%')
v-card-text v-card-text
.subheading(v-html='act.label') .subheading(v-html='act.label')
.body-1.mt-2(v-html='act.hint') .body-1.mt-2(v-html='act.hint')
v-btn.mx-0.mt-3( v-btn.mx-0.mt-3(
@click='executeAction(tgt.key, act.handler)' @click='executeAction(target.key, act.handler)'
outline outline
:color='$vuetify.dark ? `blue` : `primary`' :color='$vuetify.dark ? `blue` : `primary`'
:disabled='runningAction' :disabled='runningAction'
...@@ -205,7 +213,8 @@ export default { ...@@ -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 { ...@@ -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 { ...@@ -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',
......
...@@ -9,7 +9,7 @@ ...@@ -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 @@ ...@@ -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' ...@@ -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 { ...@@ -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)
}
} }
} }
} }
......
...@@ -5,6 +5,7 @@ query { ...@@ -5,6 +5,7 @@ query {
key key
title title
description description
isAvailable
useForm useForm
logo logo
website website
......
mutation ($file: Upload!) {
assets {
upload(data:$file) {
responseResult {
succeeded
message
}
}
}
}
...@@ -76,6 +76,7 @@ ...@@ -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 @@ ...@@ -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 @@ ...@@ -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",
......
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')
}
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')
}
...@@ -7,6 +7,7 @@ const PubSub = require('graphql-subscriptions').PubSub ...@@ -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 => { ...@@ -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)
......
# ===============================================
# 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
}
...@@ -58,6 +58,7 @@ type AuthenticationStrategy { ...@@ -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
......
...@@ -11,7 +11,6 @@ const https = require('https') ...@@ -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 */
...@@ -62,12 +61,6 @@ module.exports = async () => { ...@@ -62,12 +61,6 @@ module.exports = async () => {
})) }))
// ---------------------------------------- // ----------------------------------------
// OAuth2 Server
// ----------------------------------------
// const OAuth2Server = oauth2orize.createServer()
// ----------------------------------------
// Passport Authentication // Passport Authentication
// ---------------------------------------- // ----------------------------------------
...@@ -137,6 +130,7 @@ module.exports = async () => { ...@@ -137,6 +130,7 @@ module.exports = async () => {
path: '/graphql-subscriptions' path: '/graphql-subscriptions'
} }
}) })
app.use('/graphql', mw.upload)
apolloServer.applyMiddleware({ app }) apolloServer.applyMiddleware({ app })
// ---------------------------------------- // ----------------------------------------
......
const { graphqlUploadExpress } = require('graphql-upload')
/* global WIKI */
/**
* GraphQL File Upload Middleware
*/
module.exports = graphqlUploadExpress({ maxFileSize: 5000000, maxFiles: 20 })
...@@ -5,8 +5,21 @@ author: requarks.io ...@@ -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
...@@ -5,5 +5,6 @@ author: requarks.io ...@@ -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: {}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment