Commit d3e693ab authored by Nick's avatar Nick

feat: mandatory password change on login + UI fixes

parent 38008f04
......@@ -66,7 +66,7 @@
v-tab-item(:transition='false', :reverse-transition='false')
.body-1.pa-3 {{ $t('admin:contribute.tshirts') }}
v-card-actions.ml-2
v-btn(outline, :color='darkMode ? `blue lighten-1` : `primary`', href='https://wikijs.threadless.com', large)
v-btn(outlined, :color='darkMode ? `blue lighten-1` : `primary`', href='https://wikijs.threadless.com', large)
v-icon(left) mdi-tshirt-crew
span {{ $t('admin:contribute.shop') }}
v-divider.mt-3
......
......@@ -13,7 +13,7 @@
span {{$t('common:actions.apply')}}
v-card.mt-3.white.grey--text.text--darken-3
v-alert(color='red', value='true', icon='mdi-alert', dark, prominent)
v-alert(color='red', :value='true', icon='mdi-alert', dark, prominent)
span Do NOT enable these flags unless you know what you're doing!
.caption Doing so may result in data loss or broken installation!
v-card-text
......
......@@ -92,14 +92,14 @@
v-flex(lg6 xs12)
v-card.animated.fadeInUp.wait-p4s
v-toolbar(color='primary', dark, dense, flat)
v-toolbar(color='indigo', dark, dense, flat)
v-toolbar-title.subtitle-1 Features
v-spacer
v-chip(label, color='white', small).primary--text coming soon
v-chip(label, color='white', small).indigo--text coming soon
v-card-text
v-switch(
label='Asset Image Optimization'
color='primary'
color='indigo'
v-model='config.featureTinyPNG'
persistent-hint
hint='Image optimization tool to reduce filesize and bandwidth costs.'
......@@ -119,7 +119,7 @@
v-divider.mt-3
v-switch(
label='Page Ratings'
color='primary'
color='indigo'
v-model='config.featurePageRatings'
persistent-hint
hint='Allow users to rate pages.'
......@@ -129,7 +129,7 @@
v-divider.mt-3
v-switch(
label='Page Comments'
color='primary'
color='indigo'
v-model='config.featurePageComments'
persistent-hint
hint='Allow users to leave comments on pages.'
......@@ -139,13 +139,75 @@
v-divider.mt-3
v-switch(
label='Personal Wikis'
color='primary'
color='indigo'
v-model='config.featurePersonalWikis'
persistent-hint
hint='Allow users to have their own personal wiki.'
disabled
)
v-card.mt-5.animated.fadeInUp.wait-p5s
v-toolbar(color='red darken-2', dark, dense, flat)
v-toolbar-title.subtitle-1 Security
v-card-text
v-alert(outlined, color='red darken-2', icon='mdi-information-outline').body-2 Make sure to understand the implications before turning on / off a security feature.
v-switch.mt-3(
label='Block IFrame Embedding'
color='red darken-2'
v-model='config.securityIframe'
persistent-hint
hint='Prevents other websites from embedding your wiki in an iframe. This provides clickjacking protection.'
)
v-divider.mt-3
v-switch(
label='Same Origin Referrer Policy'
color='red darken-2'
v-model='config.securityReferrerPolicy'
persistent-hint
hint='Limits the referrer header to same origin.'
)
v-divider.mt-3
v-switch(
label='Enforce HSTS'
color='red darken-2'
v-model='config.securityHSTS'
persistent-hint
hint='This ensures the connection cannot be established through an insecure HTTP connection.'
)
v-select.mt-5(
outlined
label='HSTS Max Age'
:items='hstsDurations'
v-model='config.securityHSTSDuration'
prepend-icon='mdi-subdirectory-arrow-right'
:disabled='!config.securityHSTS'
hide-details
style='max-width: 450px;'
)
.pl-11.mt-3
.caption Defines the duration for which the server should only deliver content through HTTPS.
.caption It's a good idea to start with small values and make sure that nothing breaks on your wiki before moving to longer values.
v-divider.mt-3
v-switch(
label='Enforce CSP'
color='red darken-2'
v-model='config.securityCSP'
persistent-hint
hint='Restricts scripts to pre-approved content sources.'
disabled
)
v-textarea.mt-5(
label='CSP Directives'
outlined
v-model='config.securityCSPDirectives'
prepend-icon='mdi-subdirectory-arrow-right'
persistent-hint
hint='One directive per line.'
disabled
)
</template>
<script>
......@@ -163,12 +225,6 @@ export default {
{ text: 'Google Analytics', value: 'ga' },
{ text: 'Google Tag Manager', value: 'gtm' }
],
metaRobots: [
{ text: 'Index', value: 'index' },
{ text: 'Follow', value: 'follow' },
{ text: 'No Index', value: 'noindex' },
{ text: 'No Follow', value: 'nofollow' }
],
config: {
host: '',
title: '',
......@@ -183,8 +239,28 @@ export default {
featurePageRatings: false,
featurePageComments: false,
featurePersonalWikis: false,
featureTinyPNG: false
}
featureTinyPNG: false,
securityIframe: true,
securityReferrerPolicy: true,
securityHSTS: false,
securityHSTSDuration: 0,
securityCSP: false,
securityCSPDirectives: ''
},
hstsDurations: [
{ value: 300, text: '5 minutes' },
{ value: 86400, text: '1 day' },
{ value: 604800, text: '1 week' },
{ value: 2592000, text: '1 month' },
{ value: 31536000, text: '1 year' },
{ value: 63072000, text: '2 years' }
],
metaRobots: [
{ text: 'Index', value: 'index' },
{ text: 'Follow', value: 'follow' },
{ text: 'No Index', value: 'noindex' },
{ text: 'No Follow', value: 'nofollow' }
]
}
},
computed: {
......@@ -198,18 +274,24 @@ export default {
await this.$apollo.mutate({
mutation: siteUpdateConfigMutation,
variables: {
host: this.config.host || '',
title: this.config.title || '',
description: this.config.description || '',
robots: this.config.robots || [],
analyticsService: this.config.analyticsService || '',
analyticsId: this.config.analyticsId || '',
company: this.config.company || '',
hasLogo: this.config.hasLogo || false,
logoIsSquare: this.config.logoIsSquare || false,
featurePageRatings: this.config.featurePageRatings || false,
featurePageComments: this.config.featurePageComments || false,
featurePersonalWikis: this.config.featurePersonalWikis || false
host: _.get(this.config, 'host', ''),
title: _.get(this.config, 'title', ''),
description: _.get(this.config, 'description', ''),
robots: _.get(this.config, 'robots', []),
analyticsService: _.get(this.config, 'analyticsService', ''),
analyticsId: _.get(this.config, 'analyticsId', ''),
company: _.get(this.config, 'company', ''),
hasLogo: _.get(this.config, 'hasLogo', false),
logoIsSquare: _.get(this.config, 'logoIsSquare', false),
featurePageRatings: _.get(this.config, 'featurePageRatings', false),
featurePageComments: _.get(this.config, 'featurePageComments', false),
featurePersonalWikis: _.get(this.config, 'featurePersonalWikis', false),
securityIframe: _.get(this.config, 'securityIframe', false),
securityReferrerPolicy: _.get(this.config, 'securityReferrerPolicy', false),
securityHSTS: _.get(this.config, 'securityHSTS', false),
securityHSTSDuration: _.get(this.config, 'securityHSTSDuration', 0),
securityCSP: _.get(this.config, 'securityCSP', false),
securityCSPDirectives: _.get(this.config, 'securityCSPDirectives', '')
},
watchLoading (isLoading) {
this.$store.commit(`loading${isLoading ? 'Start' : 'Stop'}`, 'admin-site-update')
......
......@@ -23,26 +23,21 @@
must-sort,
hide-default-footer
)
template(slot='item', slot-scope='props')
tr(:active='props.selected')
td.text-xs-right {{ props.item.id }}
td {{ props.item.name }}
td {{ props.item.email }}
td
v-menu(bottom, right, min-width='200')
template(v-slot:activator='{ on }')
v-btn(icon, v-on='on', small)
v-icon.grey--text.text--darken-1 mdi-dots-horizontal
v-list(dense, nav)
v-list-item(:to='`/users/` + props.item.id')
v-list-item-action: v-icon(color='primary') mdi-account-outline
v-list-item-content
v-list-item-title View User Profile
template(v-if='props.item.id !== 2')
v-list-item(@click='unassignUser(props.item.id)')
v-list-item-action: v-icon(color='orange') mdi-account-remove-outline
v-list-item-content
v-list-item-title Unassign
template(v-slot:item.actions='{ item }')
v-menu(bottom, right, min-width='200')
template(v-slot:activator='{ on }')
v-btn(icon, v-on='on', small)
v-icon.grey--text.text--darken-1 mdi-dots-horizontal
v-list(dense, nav)
v-list-item(:to='`/users/` + item.id')
v-list-item-action: v-icon(color='primary') mdi-account-outline
v-list-item-content
v-list-item-title View User Profile
template(v-if='item.id !== 2')
v-list-item(@click='unassignUser(item.id)')
v-list-item-action: v-icon(color='orange') mdi-account-remove-outline
v-list-item-content
v-list-item-title Unassign
template(slot='no-data')
v-alert.ma-3(icon='warning', outlined) No users to display.
.text-center.py-2(v-if='group.users.length > 15')
......@@ -70,10 +65,10 @@ export default {
data() {
return {
headers: [
{ text: 'ID', value: 'id', width: 50, align: 'right' },
{ text: 'ID', value: 'id', width: 50 },
{ text: 'Name', value: 'name' },
{ text: 'Email', value: 'email' },
{ text: '', value: 'actions', sortable: false, width: 50 }
{ text: 'Actions', value: 'actions', sortable: false, width: 50 }
],
searchUserDialog: false,
pagination: 1,
......
......@@ -17,7 +17,7 @@
span New Group
v-card
.dialog-header.is-short New Group
v-card-text
v-card-text.pt-5
v-text-field.md2(
outlined
prepend-icon='mdi-account-group'
......
......@@ -30,11 +30,11 @@
template(v-slot:activator='{ on }')
v-btn.mx-1.animated.fadeInDown.wait-p1s(color='red', large, outlined, v-on='on')
v-icon(color='red') mdi-trash-can-outline
v-card.wiki-form
v-card
.dialog-header.is-short.is-red
v-icon.mr-2(color='white') mdi-file-document-box-remove-outline
span {{$t('common:page.delete')}}
v-card-text
v-card-text.pt-5
i18next.body-2(path='common:page.deleteTitle', tag='div')
span.red--text.text--darken-2(place='title') {{page.title}}
.caption {{$t('common:page.deleteSubtitle')}}
......@@ -44,7 +44,7 @@
span.red--text.text--darken-2 /{{page.path}}
v-card-chin
v-spacer
v-btn(flat, @click='deletePageDialog = false', :disabled='loading') {{$t('common:actions.cancel')}}
v-btn(text, @click='deletePageDialog = false', :disabled='loading') {{$t('common:actions.cancel')}}
v-btn(color='red darken-2', @click='deletePage', :loading='loading').white--text {{$t('common:actions.delete')}}
v-btn.ml-1.animated.fadeInDown(color='teal', large, outlined, @click='rerenderPage')
v-icon(left) mdi-cube-scan
......
......@@ -64,7 +64,7 @@
td {{ props.item.createdAt | moment('calendar') }}
td {{ props.item.updatedAt | moment('calendar') }}
template(slot='no-data')
v-alert.ma-3(icon='warning', :value='true', outline) No pages to display.
v-alert.ma-3(icon='mdi-alert', :value='true', outlined) No pages to display.
.text-xs-center.py-2.animated.fadeInDown(v-if='this.pageTotal > 1')
v-pagination(v-model='pagination', :length='pageTotal')
</template>
......
......@@ -26,8 +26,8 @@
v-list-item(:key='eng.key', @click='selectedEngine = eng.key', :disabled='!eng.isAvailable')
v-list-item-avatar(size='24')
v-icon(color='grey', v-if='!eng.isAvailable') mdi-minus-box-outline
v-icon(color='primary', v-else-if='eng.key === selectedEngine') mdi-checkbox-marked-outline
v-icon(color='grey', v-else) mdi-checkbox-blank-outline
v-icon(color='primary', v-else-if='eng.key === selectedEngine') mdi-checkbox-marked-circle-outline
v-icon(color='grey', v-else) mdi-checkbox-blank-circle-outline
v-list-item-content
v-list-item-title.body-2(:class='!eng.isAvailable ? `grey--text` : (selectedEngine === eng.key ? `primary--text` : ``)') {{ eng.title }}
v-list-item-subtitle: .caption(:class='!eng.isAvailable ? `grey--text text--lighten-1` : (selectedEngine === eng.key ? `blue--text ` : ``)') {{ eng.description }}
......
......@@ -49,7 +49,7 @@
v-icon(color='white') mdi-clock-outline
v-list-item-content
v-list-item-title.body-2 {{tgt.title}}
v-list-item-sub-title.purple--text.caption {{tgt.status}}
v-list-item-subtitle.purple--text.caption {{tgt.status}}
v-list-item-action
v-progress-circular(indeterminate, :size='20', :width='2', color='purple')
template(v-else-if='tgt.status === `operational`')
......@@ -57,13 +57,13 @@
v-icon(color='white') mdi-check-circle
v-list-item-content
v-list-item-title.body-2 {{tgt.title}}
v-list-item-sub-title.green--text.caption {{$t('admin:storage.lastSync', { time: $options.filters.moment(tgt.lastAttempt, 'from') })}}
v-list-item-subtitle.green--text.caption {{$t('admin:storage.lastSync', { time: $options.filters.moment(tgt.lastAttempt, 'from') })}}
template(v-else)
v-list-item-avatar(color='red')
v-icon(color='white') mdi-close-circle-outline
v-list-item-content
v-list-item-title.body-2 {{tgt.title}}
v-list-item-sub-title.red--text.caption {{$t('admin:storage.lastSyncAttempt', { time: $options.filters.moment(tgt.lastAttempt, 'from') })}}
v-list-item-subtitle.red--text.caption {{$t('admin:storage.lastSyncAttempt', { time: $options.filters.moment(tgt.lastAttempt, 'from') })}}
v-list-item-action
v-menu
v-btn(slot='activator', icon)
......@@ -86,6 +86,10 @@
img(:src='target.logo', :alt='target.title')
.body-2.pt-3 {{target.description}}
.body-2.pt-3.pb-5: a(:href='target.website') {{target.website}}
i18next.body-2(path='admin:storage.targetState', tag='div', v-if='target.isEnabled')
v-chip(color='green', small, dark, label, place='state') {{$t('admin:storage.targetStateActive')}}
i18next.body-2(path='admin:storage.targetState', tag='div', v-else)
v-chip(color='red', small, dark, label, place='state') {{$t('admin:storage.targetStateInactive')}}
v-divider.mt-3
.overline.my-5 {{$t('admin:storage.targetConfig')}}
.body-2.ml-3(v-if='!target.config || target.config.length < 1'): em {{$t('admin:storage.noConfigOption')}}
......@@ -179,6 +183,8 @@
template(v-if='target.actions && target.actions.length > 0')
v-divider.mt-3
.overline.my-5 {{$t('admin:storage.actions')}}
v-alert(outlined, :value='!target.isEnabled', color='red', icon='mdi-alert')
.body-2 {{$t('admin:storage.actionsInactiveWarn')}}
v-container.pt-0(grid-list-xl, fluid)
v-layout(row, wrap, fill-height)
v-flex(xs12, lg6, xl4, v-for='act of target.actions', :key='act.handler')
......@@ -190,7 +196,7 @@
@click='executeAction(target.key, act.handler)'
outlined
:color='$vuetify.theme.dark ? `blue` : `primary`'
:disabled='runningAction'
:disabled='runningAction || !target.isEnabled'
:loading='runningActionHandler === act.handler'
) {{$t('admin:storage.actionRun')}}
......
......@@ -13,13 +13,13 @@
v-btn.animated.fadeInLeft.wait-p2s.btn-animate-rotate(fab, absolute, :right='!$vuetify.rtl', :left='$vuetify.rtl', top, small, light, @click='refresh'): v-icon(color='grey') mdi-refresh
v-subheader Wiki.js
v-list(two-line, dense)
v-list-item(avatar)
v-list-item
v-list-item-avatar
v-icon.blue.white--text mdi-application-export
v-list-item-content
v-list-item-title {{ $t('admin:system.currentVersion') }}
v-list-item-subtitle {{ info.currentVersion }}
v-list-item(avatar)
v-list-item
v-list-item-avatar
v-icon.blue.white--text mdi-inbox-arrow-up
v-list-item-content
......@@ -31,38 +31,38 @@
v-divider.mt-3
v-subheader {{ $t('admin:system.hostInfo') }}
v-list(two-line, dense)
v-list-item(avatar)
v-list-item
v-list-item-avatar
v-avatar.blue-grey(size='40')
v-icon(color='white') {{platformLogo}}
v-list-item-content
v-list-item-title {{ $t('admin:system.os') }}
v-list-item-subtitle {{ (info.platform === 'docker') ? 'Docker Container (Linux)' : info.operatingSystem }}
v-list-item(avatar)
v-list-item
v-list-item-avatar
v-icon.blue-grey.white--text mdi-desktop-classic
v-list-item-content
v-list-item-title {{ $t('admin:system.hostname') }}
v-list-item-subtitle {{ info.hostname }}
v-list-item(avatar)
v-list-item
v-list-item-avatar
v-icon.blue-grey.white--text mdi-cpu-64-bit
v-list-item-content
v-list-item-title {{ $t('admin:system.cpuCores') }}
v-list-item-subtitle {{ info.cpuCores }}
v-list-item(avatar)
v-list-item
v-list-item-avatar
v-icon.blue-grey.white--text mdi-memory
v-list-item-content
v-list-item-title {{ $t('admin:system.totalRAM') }}
v-list-item-subtitle {{ info.ramTotal }}
v-list-item(avatar)
v-list-item
v-list-item-avatar
v-icon.blue-grey.white--text mdi-iframe-outline
v-list-item-content
v-list-item-title {{ $t('admin:system.workingDirectory') }}
v-list-item-subtitle {{ info.workingDirectory }}
v-list-item(avatar)
v-list-item
v-list-item-avatar
v-icon.blue-grey.white--text mdi-card-bulleted-settings-outline
v-list-item-content
......@@ -73,7 +73,7 @@
v-card.pb-3.animated.fadeInUp.wait-p4s
v-subheader Node.js
v-list(dense)
v-list-item(avatar)
v-list-item
v-list-item-avatar
v-avatar.light-green(size='40')
v-icon(color='white') mdi-nodejs
......@@ -83,7 +83,7 @@
v-divider.mt-3
v-subheader {{ info.dbType }}
v-list(dense)
v-list-item(avatar)
v-list-item
v-list-item-avatar
v-avatar.indigo.darken-1(size='40')
v-icon(color='white') mdi-database
......
......@@ -8,7 +8,7 @@
v-btn.mx-0(color='white', outlined, disabled, dark)
v-icon(left) mdi-database-import
span Bulk Import
v-card-text
v-card-text.pt-5
v-select(
:items='providers'
item-text='title'
......@@ -89,6 +89,7 @@
label='Send a welcome email'
hide-details
v-model='sendWelcomeEmail'
disabled
)
v-card-chin
v-spacer
......
......@@ -4,7 +4,7 @@
.dialog-header.is-short.is-red
v-icon.mr-2(color='white') mdi-file-document-box-remove-outline
span {{$t('common:page.delete')}}
v-card-text
v-card-text.pt-5
i18next.body-1(path='common:page.deleteTitle', tag='div')
span.red--text.text--darken-2(place='title') {{pageTitle}}
.caption {{$t('common:page.deleteSubtitle')}}
......
......@@ -3,7 +3,7 @@
v-model='dialogOpen'
max-width='650'
)
v-card.wiki-form
v-card
.dialog-header
span {{$t('common:user.search')}}
v-spacer
......@@ -14,7 +14,7 @@
:width='2'
v-show='searchLoading'
)
v-card-text
v-card-text.pt-5
v-text-field(
outlined
:label='$t(`common:user.searchPlaceholder`)'
......@@ -56,7 +56,7 @@ import searchUsersQuery from 'gql/common/common-users-query-search.gql'
export default {
filters: {
initials(val) {
return val.split(' ').map(v => v.substring(0, 1)).join()
return val.split(' ').map(v => v.substring(0, 1)).join('')
}
},
props: {
......
......@@ -11,6 +11,12 @@ mutation (
$featurePageRatings: Boolean!
$featurePageComments: Boolean!
$featurePersonalWikis: Boolean!
$securityIframe: Boolean!
$securityReferrerPolicy: Boolean!
$securityHSTS: Boolean!
$securityHSTSDuration: Int!
$securityCSP: Boolean!
$securityCSPDirectives: String!
) {
site {
updateConfig(
......@@ -25,7 +31,13 @@ mutation (
logoIsSquare: $logoIsSquare,
featurePageRatings: $featurePageRatings,
featurePageComments: $featurePageComments,
featurePersonalWikis: $featurePersonalWikis
featurePersonalWikis: $featurePersonalWikis,
securityIframe: $securityIframe,
securityReferrerPolicy: $securityReferrerPolicy,
securityHSTS: $securityHSTS,
securityHSTSDuration: $securityHSTSDuration,
securityCSP: $securityCSP,
securityCSPDirectives: $securityCSPDirectives
) {
responseResult {
succeeded
......
......@@ -13,6 +13,12 @@
featurePageRatings
featurePageComments
featurePersonalWikis
securityIframe
securityReferrerPolicy
securityHSTS
securityHSTSDuration
securityCSP
securityCSPDirectives
}
}
}
......@@ -10,6 +10,8 @@ query ($id: Int!) {
jobTitle
timezone
isSystem
isActive
isVerified
createdAt
updatedAt
groups {
......
mutation($continuationToken: String!, $newPassword: String!) {
authentication {
loginChangePassword(continuationToken: $continuationToken, newPassword: $newPassword) {
responseResult {
succeeded
errorCode
slug
message
}
jwt
}
}
}
......@@ -8,8 +8,9 @@ mutation($username: String!, $password: String!, $strategy: String!) {
message
}
jwt
tfaRequired
tfaLoginToken
mustChangePwd
mustProvideTFA
continuationToken
}
}
}
mutation($loginToken: String!, $securityCode: String!) {
mutation($continuationToken: String!, $securityCode: String!) {
authentication {
loginTFA(loginToken: $loginToken, securityCode: $securityCode) {
loginTFA(continuationToken: $continuationToken, securityCode: $securityCode) {
responseResult {
succeeded
errorCode
slug
message
}
jwt
}
}
}
......@@ -6,6 +6,7 @@
justify-content: center;
align-items: center;
color: mc('grey', '50');
font-family: Roboto, Arial, sans-serif;
img {
width: 250px;
......@@ -57,8 +58,20 @@
}
}
code {
color: mc('grey', '500');
font-size: .8rem;
> strong {
font-size: 1.5rem;
}
> span {
margin-top: 1rem;
}
> pre {
margin-top: 2rem;
code {
color: mc('grey', '500');
font-size: .8rem;
}
}
}
......@@ -70,20 +70,7 @@
v-list-item-title.px-3.caption.grey--text(:class='darkMode ? `text--lighten-1` : `text--darken-1`') {{tocSubItem.title}}
//- v-divider(inset, v-if='tocIdx < toc.length - 1')
v-card.mt-5
.pa-5.pt-3
.overline.indigo--text.d-flex.align-center(:class='$vuetify.theme.dark ? `text--lighten-3` : ``')
span {{$t('common:page.lastEditedBy')}}
v-spacer
v-tooltip(top, v-if='isAuthenticated')
template(v-slot:activator='{ on }')
v-btn.btn-animate-edit(icon, :href='"/h/" + locale + "/" + path', v-on='on', x-small)
v-icon(color='grey', dense) mdi-history
span History
.body-2.grey--text(:class='darkMode ? `` : `text--darken-3`') {{ authorName }}
.caption.grey--text.text--darken-1 {{ updatedAt | moment('calendar') }}
v-card.mt-5(v-if='tags.length > 0 || true')
v-card.mt-5(v-if='tags.length > 0')
.pa-5
.overline.teal--text.pb-2(:class='$vuetify.theme.dark ? `text--lighten-3` : ``') Tags
v-chip.mr-1(
......@@ -98,6 +85,19 @@
v-card.mt-5
.pa-5
.overline.indigo--text.d-flex.align-center(:class='$vuetify.theme.dark ? `text--lighten-3` : ``')
span {{$t('common:page.lastEditedBy')}}
v-spacer
v-tooltip(top, v-if='isAuthenticated')
template(v-slot:activator='{ on }')
v-btn.btn-animate-edit(icon, :href='"/h/" + locale + "/" + path', v-on='on', x-small)
v-icon(color='grey', dense) mdi-history
span History
.body-2.grey--text(:class='darkMode ? `` : `text--darken-3`') {{ authorName }}
.caption.grey--text.text--darken-1 {{ updatedAt | moment('calendar') }}
v-card.mt-5
.pa-5
.overline.pb-2.yellow--text(:class='$vuetify.theme.dark ? `text--darken-3` : `text--darken-4`') Rating
.text-center
v-rating(
......@@ -108,20 +108,21 @@
hover
)
.caption.grey--text 5 votes
v-divider
v-toolbar(:color='darkMode ? `grey darken-3` : `grey lighten-4`', flat, dense)
v-card.mt-5(flat)
v-toolbar(:color='darkMode ? `grey darken-3` : `grey lighten-3`', flat, dense)
v-spacer
v-tooltip(bottom)
template(v-slot:activator='{ on }')
v-btn(icon, tile, small, v-on='on'): v-icon(color='grey') mdi-bookmark
v-btn(icon, tile, v-on='on'): v-icon(color='grey') mdi-bookmark
span {{$t('common:page.bookmark')}}
v-tooltip(bottom)
template(v-slot:activator='{ on }')
v-btn(icon, tile, small, v-on='on'): v-icon(color='grey') mdi-share-variant
v-btn(icon, tile, v-on='on'): v-icon(color='grey') mdi-share-variant
span {{$t('common:page.share')}}
v-tooltip(bottom)
template(v-slot:activator='{ on }')
v-btn(icon, tile, small, v-on='on'): v-icon(color='grey') mdi-printer
v-btn(icon, tile, v-on='on'): v-icon(color='grey') mdi-printer
span {{$t('common:page.printFormat')}}
v-spacer
......
......@@ -32,7 +32,7 @@ html
link(
type='text/css'
rel='stylesheet'
href='https://use.fontawesome.com/releases/v5.9.0/css/all.css'
href='https://use.fontawesome.com/releases/v5.10.0/css/all.css'
)
else if config.theming.iconset === 'fa4'
link(
......
......@@ -36,7 +36,7 @@ html(lang=siteConfig.lang)
link(
type='text/css'
rel='stylesheet'
href='https://use.fontawesome.com/releases/v5.9.0/css/all.css'
href='https://use.fontawesome.com/releases/v5.10.0/css/all.css'
)
else if config.theming.iconset === 'fa4'
link(
......
......@@ -35,13 +35,13 @@
},
"dependencies": {
"@aoberoi/passport-slack": "1.0.5",
"@bugsnag/js": "6.3.2",
"@bugsnag/js": "6.4.0",
"algoliasearch": "3.33.0",
"apollo-fetch": "0.7.0",
"apollo-server": "2.8.1",
"apollo-server-express": "2.8.1",
"apollo-server": "2.9.0",
"apollo-server-express": "2.9.0",
"auto-load": "3.0.4",
"aws-sdk": "2.503.0",
"aws-sdk": "2.517.0",
"axios": "0.19.0",
"azure-search-client": "3.1.5",
"bcryptjs-then": "1.0.1",
......@@ -67,18 +67,18 @@
"express": "4.17.1",
"express-brute": "1.0.1",
"express-session": "1.16.2",
"file-type": "12.1.0",
"file-type": "12.2.0",
"filesize": "4.1.2",
"fs-extra": "8.1.0",
"getos": "3.1.1",
"graphql": "14.4.2",
"graphql": "14.5.3",
"graphql-list-fields": "2.0.2",
"graphql-rate-limit-directive": "1.1.0",
"graphql-subscriptions": "1.1.0",
"graphql-tools": "4.0.5",
"highlight.js": "9.15.9",
"i18next": "17.0.8",
"i18next-express-middleware": "1.8.0",
"highlight.js": "9.15.10",
"i18next": "17.0.12",
"i18next-express-middleware": "1.8.1",
"i18next-node-fs-backend": "2.1.3",
"image-size": "0.7.4",
"js-base64": "2.5.1",
......@@ -86,12 +86,12 @@
"js-yaml": "3.13.1",
"jsonwebtoken": "8.5.1",
"klaw": "3.0.0",
"knex": "0.19.1",
"knex": "0.19.2",
"lodash": "4.17.15",
"markdown-it": "9.0.1",
"markdown-it": "9.1.0",
"markdown-it-abbr": "1.0.4",
"markdown-it-anchor": "5.2.4",
"markdown-it-attrs": "3.0.0",
"markdown-it-attrs": "3.0.1",
"markdown-it-emoji": "1.4.0",
"markdown-it-expand-tabs": "1.0.13",
"markdown-it-external-links": "0.0.6",
......@@ -106,7 +106,7 @@
"mime-types": "2.1.24",
"moment": "2.24.0",
"moment-timezone": "0.5.26",
"mongodb": "3.2.7",
"mongodb": "3.3.1",
"mssql": "5.1.0",
"multer": "1.4.2",
"mysql2": "1.6.5",
......@@ -116,7 +116,7 @@
"nodemailer": "6.3.0",
"objection": "1.6.9",
"passport": "0.4.0",
"passport-auth0": "1.2.0",
"passport-auth0": "1.2.1",
"passport-azure-ad": "4.1.0",
"passport-cas": "0.1.1",
"passport-discord": "0.1.3",
......@@ -135,7 +135,7 @@
"passport-saml": "1.1.0",
"passport-twitch": "1.0.3",
"pem-jwk": "2.0.0",
"pg": "7.12.0",
"pg": "7.12.1",
"pg-hstore": "2.3.3",
"pg-query-stream": "2.0.0",
"pg-tsquery": "8.0.5",
......@@ -152,18 +152,18 @@
"serve-favicon": "2.5.0",
"simple-git": "1.124.0",
"solr-node": "1.2.1",
"sqlite3": "4.0.9",
"sqlite3": "4.1.0",
"striptags": "3.1.1",
"subscriptions-transport-ws": "0.9.16",
"tar-fs": "2.0.0",
"twemoji": "12.1.2",
"uslug": "1.0.4",
"uuid": "3.3.2",
"uuid": "3.3.3",
"validate.js": "0.13.1",
"validator": "11.1.0",
"validator-as-promised": "1.0.2",
"winston": "3.2.1",
"yargs": "13.3.0"
"yargs": "14.0.0"
},
"devDependencies": {
"@babel/cli": "^7.5.0",
......@@ -179,13 +179,13 @@
"@babel/plugin-syntax-import-meta": "^7.2.0",
"@babel/polyfill": "^7.4.4",
"@babel/preset-env": "^7.5.4",
"@mdi/font": "3.8.95",
"@mdi/font": "4.1.95",
"@panter/vue-i18next": "0.15.1",
"@vue/babel-preset-app": "3.10.0",
"@vue/babel-preset-app": "3.11.0",
"animate-sass": "0.8.2",
"animated-number-vue": "1.0.0",
"apollo-cache-inmemory": "1.6.2",
"apollo-client": "2.6.3",
"apollo-cache-inmemory": "1.6.3",
"apollo-client": "2.6.4",
"apollo-link": "1.2.12",
"apollo-link-batch-http": "1.2.12",
"apollo-link-error": "1.1.11",
......@@ -195,9 +195,9 @@
"apollo-utilities": "1.3.2",
"autoprefixer": "9.6.1",
"babel-eslint": "10.0.2",
"babel-jest": "24.8.0",
"babel-jest": "24.9.0",
"babel-loader": "^8.0.6",
"babel-plugin-graphql-tag": "2.4.0",
"babel-plugin-graphql-tag": "2.5.0",
"babel-plugin-lodash": "3.3.4",
"babel-plugin-prismjs": "1.1.1",
"babel-plugin-transform-imports": "2.0.0",
......@@ -206,26 +206,26 @@
"chart.js": "2.8.0",
"clean-webpack-plugin": "3.0.0",
"copy-webpack-plugin": "5.0.4",
"core-js": "3.1.4",
"css-loader": "3.1.0",
"core-js": "3.2.1",
"css-loader": "3.2.0",
"cssnano": "4.1.10",
"duplicate-package-checker-webpack-plugin": "3.0.0",
"epic-spinners": "1.1.0",
"eslint": "6.1.0",
"eslint": "6.2.2",
"eslint-config-requarks": "1.0.7",
"eslint-config-standard": "13.0.1",
"eslint-config-standard": "14.0.1",
"eslint-plugin-import": "2.18.2",
"eslint-plugin-node": "9.1.0",
"eslint-plugin-promise": "4.2.1",
"eslint-plugin-standard": "4.0.0",
"eslint-plugin-standard": "4.0.1",
"eslint-plugin-vue": "5.2.3",
"fibers": "4.0.1",
"file-loader": "4.1.0",
"filepond": "4.4.12",
"file-loader": "4.2.0",
"filepond": "4.5.0",
"filepond-plugin-file-validate-type": "1.2.4",
"filesize.js": "1.0.2",
"grapesjs": "0.14.62",
"graphiql": "0.13.2",
"grapesjs": "0.15.3",
"graphiql": "0.14.2",
"graphql-persisted-document-loader": "1.0.1",
"graphql-tag": "^2.10.1",
"graphql-voyager": "1.0.0-rc.27",
......@@ -234,10 +234,10 @@
"html-webpack-pug-plugin": "2.0.0",
"i18next-chained-backend": "2.0.0",
"i18next-localstorage-backend": "3.0.0",
"i18next-xhr-backend": "3.0.1",
"i18next-xhr-backend": "3.1.2",
"ignore-loader": "0.1.2",
"jest": "24.8.0",
"js-cookie": "2.2.0",
"jest": "24.9.0",
"js-cookie": "2.2.1",
"mini-css-extract-plugin": "0.8.0",
"moment-duration-format": "2.3.2",
"offline-plugin": "5.0.7",
......@@ -254,19 +254,19 @@
"pug-loader": "2.4.0",
"pug-plain-loader": "1.0.0",
"raw-loader": "3.1.0",
"react": "16.8.6",
"react-dom": "16.8.6",
"react": "16.9.0",
"react-dom": "16.9.0",
"resolve-url-loader": "3.1.0",
"sass": "1.22.9",
"sass-loader": "7.1.0",
"sass": "1.22.10",
"sass-loader": "7.3.1",
"sass-resources-loader": "2.0.1",
"script-ext-html-webpack-plugin": "2.1.4",
"simple-progress-webpack-plugin": "1.1.2",
"style-loader": "0.23.1",
"terser": "4.1.3",
"style-loader": "1.0.0",
"terser": "4.2.1",
"twemoji-awesome": "1.0.6",
"url-loader": "2.1.0",
"vee-validate": "2.2.13",
"vee-validate": "2.2.15",
"velocity-animate": "1.5.2",
"viz.js": "2.1.2",
"vue": "2.6.10",
......@@ -274,32 +274,32 @@
"vue-chartjs": "3.4.2",
"vue-clipboards": "1.3.0",
"vue-codemirror": "4.0.6",
"vue-filepond": "5.1.2",
"vue-filepond": "5.1.3",
"vue-hot-reload-api": "2.3.3",
"vue-loader": "15.7.1",
"vue-material-design-icons": "3.3.1",
"vue-material-design-icons": "3.4.0",
"vue-moment": "4.0.0",
"vue-router": "3.0.7",
"vue-router": "3.1.2",
"vue-simple-breakpoints": "1.0.3",
"vue-status-indicator": "1.2.1",
"vue-template-compiler": "2.6.10",
"vue-tour": "1.1.0",
"vue2-animate": "2.1.0",
"vue2-animate": "2.1.2",
"vuedraggable": "2.23.0",
"vuescroll": "4.13.1",
"vuetify": "2.0.4",
"vuescroll": "4.14.0",
"vuetify": "2.0.10",
"vuetify-loader": "1.3.0",
"vuex": "3.1.1",
"vuex-pathify": "1.2.4",
"vuex-persistedstate": "2.5.4",
"webpack": "4.39.1",
"webpack": "4.39.2",
"webpack-bundle-analyzer": "3.4.1",
"webpack-cli": "3.3.6",
"webpack-cli": "3.3.7",
"webpack-dev-middleware": "3.7.0",
"webpack-hot-middleware": "2.25.0",
"webpack-merge": "4.2.1",
"webpack-subresource-integrity": "1.3.2",
"webpackbar": "3.2.0",
"webpackbar": "4.0.0",
"whatwg-fetch": "3.0.0",
"write-file-webpack-plugin": "4.5.1",
"xterm": "3.14.5",
......@@ -344,7 +344,10 @@
"requireSpaceAfterCodeOperator": true,
"requireStrictEqualityOperators": true,
"validateAttributeQuoteMarks": "'",
"validateAttributeSeparator": { "separator": ", ", "multiLineSeparator": "\n " },
"validateAttributeSeparator": {
"separator": ", ",
"multiLineSeparator": "\n "
},
"validateDivTags": true,
"validateIndentation": 2,
"excludeFiles": [
......
......@@ -42,6 +42,13 @@ defaults:
theme: 'default'
iconset: 'md'
darkMode: false
security:
securityIframe: true
securityReferrerPolicy: true
securityHSTS: false
securityHSTSDuration: 300
securityCSP: false
securityCSPDirectives: ''
flags:
ldapdebug: false
sqllog: false
......
......@@ -7,16 +7,16 @@ const graphHelper = require('../../helpers/graph')
module.exports = {
Query: {
async authentication() { return {} }
async authentication () { return {} }
},
Mutation: {
async authentication() { return {} }
async authentication () { return {} }
},
AuthenticationQuery: {
/**
* Fetch active authentication strategies
*/
async strategies(obj, args, context, info) {
async strategies (obj, args, context, info) {
let strategies = await WIKI.models.authentication.getStrategies(args.isEnabled)
strategies = strategies.map(stg => {
const strategyInfo = _.find(WIKI.data.authentication, ['key', stg.key]) || {}
......@@ -44,7 +44,7 @@ module.exports = {
/**
* Perform Login
*/
async login(obj, args, context) {
async login (obj, args, context) {
try {
const authResult = await WIKI.models.users.login(args, context)
return {
......@@ -63,7 +63,7 @@ module.exports = {
/**
* Perform 2FA Login
*/
async loginTFA(obj, args, context) {
async loginTFA (obj, args, context) {
try {
const authResult = await WIKI.models.users.loginTFA(args, context)
return {
......@@ -75,9 +75,23 @@ module.exports = {
}
},
/**
* Perform Mandatory Password Change after Login
*/
async loginChangePassword (obj, args, context) {
try {
const authResult = await WIKI.models.users.loginChangePassword(args, context)
return {
...authResult,
responseResult: graphHelper.generateSuccess('Password changed successfully')
}
} catch (err) {
return graphHelper.generateError(err)
}
},
/**
* Register a new account
*/
async register(obj, args, context) {
async register (obj, args, context) {
try {
await WIKI.models.users.register({ ...args, verify: true }, context)
return {
......@@ -90,7 +104,7 @@ module.exports = {
/**
* Update Authentication Strategies
*/
async updateStrategies(obj, args, context) {
async updateStrategies (obj, args, context) {
try {
WIKI.config.auth = {
audience: _.get(args, 'config.audience', WIKI.config.auth.audience),
......@@ -122,7 +136,7 @@ module.exports = {
/**
* Generate New Authentication Public / Private Key Certificates
*/
async regenerateCertificates(obj, args, context) {
async regenerateCertificates (obj, args, context) {
try {
await WIKI.auth.regenerateCertificates()
return {
......@@ -135,7 +149,7 @@ module.exports = {
/**
* Reset Guest User
*/
async resetGuestUser(obj, args, context) {
async resetGuestUser (obj, args, context) {
try {
await WIKI.auth.resetGuestUser()
return {
......
......@@ -17,7 +17,8 @@ module.exports = {
company: WIKI.config.company,
...WIKI.config.seo,
...WIKI.config.logo,
...WIKI.config.features
...WIKI.config.features,
...WIKI.config.security
}
}
},
......@@ -42,7 +43,15 @@ module.exports = {
featurePageComments: args.featurePageComments,
featurePersonalWikis: args.featurePersonalWikis
}
await WIKI.configSvc.saveToDb(['host', 'title', 'company', 'seo', 'logo', 'features'])
WIKI.config.security = {
securityIframe: args.securityIframe,
securityReferrerPolicy: args.securityReferrerPolicy,
securityHSTS: args.securityHSTS,
securityHSTSDuration: args.securityHSTSDuration,
securityCSP: args.securityCSP,
securityCSPDirectives: args.securityCSPDirectives
}
await WIKI.configSvc.saveToDb(['host', 'title', 'company', 'seo', 'logo', 'features', 'security'])
return {
responseResult: graphHelper.generateSuccess('Site configuration updated successfully')
......
......@@ -32,9 +32,14 @@ type AuthenticationMutation {
): AuthenticationLoginResponse @rateLimit(limit: 5, duration: 60)
loginTFA(
loginToken: String!
continuationToken: String!
securityCode: String!
): DefaultResponse @rateLimit(limit: 5, duration: 60)
): AuthenticationLoginResponse @rateLimit(limit: 5, duration: 60)
loginChangePassword(
continuationToken: String!
newPassword: String!
): AuthenticationLoginResponse @rateLimit(limit: 5, duration: 60)
register(
email: String!
......@@ -76,8 +81,9 @@ type AuthenticationStrategy {
type AuthenticationLoginResponse {
responseResult: ResponseStatus
jwt: String
tfaRequired: Boolean
tfaLoginToken: String
mustChangePwd: Boolean
mustProvideTFA: Boolean
continuationToken: String
}
type AuthenticationRegisterResponse {
......
......@@ -36,6 +36,12 @@ type SiteMutation {
featurePageRatings: Boolean!
featurePageComments: Boolean!
featurePersonalWikis: Boolean!
securityIframe: Boolean!
securityReferrerPolicy: Boolean!
securityHSTS: Boolean!
securityHSTSDuration: Int!
securityCSP: Boolean!
securityCSPDirectives: String!
): DefaultResponse @auth(requires: ["manage:system"])
}
......@@ -56,4 +62,10 @@ type SiteConfig {
featurePageRatings: Boolean!
featurePageComments: Boolean!
featurePersonalWikis: Boolean!
securityIframe: Boolean!
securityReferrerPolicy: Boolean!
securityHSTS: Boolean!
securityHSTSDuration: Int!
securityCSP: Boolean!
securityCSPDirectives: String!
}
......@@ -89,6 +89,8 @@ type User {
providerKey: String!
providerId: String
isSystem: Boolean!
isActive: Boolean!
isVerified: Boolean!
location: String!
jobTitle: String!
timezone: String!
......
'use strict'
/* global WIKI */
/**
* Security Middleware
......@@ -13,7 +13,9 @@ module.exports = function (req, res, next) {
req.app.disable('x-powered-by')
// -> Disable Frame Embedding
res.set('X-Frame-Options', 'deny')
if (WIKI.config.securityIframe) {
res.set('X-Frame-Options', 'deny')
}
// -> Re-enable XSS Fitler if disabled
res.set('X-XSS-Protection', '1; mode=block')
......@@ -25,7 +27,14 @@ module.exports = function (req, res, next) {
res.set('X-UA-Compatible', 'IE=edge')
// -> Disables referrer header when navigating to a different origin
res.set('Referrer-Policy', 'same-origin')
if (WIKI.config.securityReferrerPolicy) {
res.set('Referrer-Policy', 'same-origin')
}
// -> Enforce HSTS
if (WIKI.config.securityHSTS) {
res.set('Strict-Transport-Security', `max-age=${WIKI.config.securityHSTSDuration}; includeSubDomains`)
}
return next()
}
......@@ -45,7 +45,7 @@ module.exports = class UserKey extends Model {
}
static async generateToken ({ userId, kind }, context) {
const token = await nanoid()
const token = nanoid()
await WIKI.models.userKeys.query().insert({
kind,
token,
......
......@@ -3,7 +3,6 @@
const bcrypt = require('bcryptjs-then')
const _ = require('lodash')
const tfa = require('node-2fa')
const securityHelper = require('../helpers/security')
const jwt = require('jsonwebtoken')
const Model = require('objection').Model
const validate = require('validate.js')
......@@ -280,30 +279,46 @@ module.exports = class User extends Model {
if (err) { return reject(err) }
if (!user) { return reject(new WIKI.Error.AuthLoginFailed()) }
// Must Change Password?
if (user.mustChangePwd) {
try {
const pwdChangeToken = await WIKI.models.userKeys.generateToken({
kind: 'changePwd',
userId: user.id
})
return resolve({
mustChangePwd: true,
continuationToken: pwdChangeToken
})
} catch (err) {
WIKI.logger.warn(err)
return reject(new WIKI.Error.AuthGenericError())
}
}
// Is 2FA required?
if (user.tfaIsActive) {
try {
let loginToken = await securityHelper.generateToken(32)
await WIKI.redis.set(`tfa:${loginToken}`, user.id, 'EX', 600)
const tfaToken = await WIKI.models.userKeys.generateToken({
kind: 'tfa',
userId: user.id
})
return resolve({
tfaRequired: true,
tfaLoginToken: loginToken
continuationToken: tfaToken
})
} catch (err) {
WIKI.logger.warn(err)
return reject(new WIKI.Error.AuthGenericError())
}
} else {
// No 2FA, log in user
return context.req.logIn(user, { session: !strInfo.useForm }, async err => {
if (err) { return reject(err) }
const jwtToken = await WIKI.models.users.refreshToken(user)
resolve({
jwt: jwtToken.token,
tfaRequired: false
})
})
}
context.req.logIn(user, { session: !strInfo.useForm }, async err => {
if (err) { return reject(err) }
const jwtToken = await WIKI.models.users.refreshToken(user)
resolve({ jwt: jwtToken.token })
})
})(context.req, context.res, () => {})
})
} else {
......@@ -348,7 +363,7 @@ module.exports = class User extends Model {
}
}
static async loginTFA(opts, context) {
static async loginTFA (opts, context) {
if (opts.securityCode.length === 6 && opts.loginToken.length === 64) {
let result = await WIKI.redis.get(`tfa:${opts.loginToken}`)
if (result) {
......@@ -375,6 +390,36 @@ module.exports = class User extends Model {
}
/**
* Change Password from a Mandatory Password Change after Login
*/
static async loginChangePassword ({ continuationToken, newPassword }, context) {
if (!newPassword || newPassword.length < 6) {
throw new WIKI.Error.InputInvalid('Password must be at least 6 characters!')
}
const usr = await WIKI.models.userKeys.validateToken({
kind: 'changePwd',
token: continuationToken
})
if (usr) {
await WIKI.models.users.query().patch({
password: newPassword,
mustChangePwd: false
}).findById(usr.id)
return new Promise((resolve, reject) => {
context.req.logIn(usr, { session: false }, async err => {
if (err) { return reject(err) }
const jwtToken = await WIKI.models.users.refreshToken(usr)
resolve({ jwt: jwtToken.token })
})
})
} else {
throw new WIKI.Error.UserNotFound()
}
}
/**
* Create a new user
*
* @param {Object} param0 User Fields
......@@ -520,7 +565,7 @@ module.exports = class User extends Model {
}
usrData.password = newPassword
}
if (!_.isEmpty(groups)) {
if (_.isArray(groups)) {
const usrGroupsRaw = await usr.$relatedQuery('groups')
const usrGroups = _.map(usrGroupsRaw, 'id')
// Relate added groups
......
......@@ -3,7 +3,7 @@ title: Local
description: Built-in authentication for Wiki.js
author: requarks.io
logo: https://static.requarks.io/logo/wikijs.svg
color: yellow darken-3
color: primary
website: https://wiki.js.org
isAvailable: true
useForm: true
......
......@@ -2,25 +2,11 @@ extends master.pug
block body
#root.is-fullscreen
v-app(dark)
.app-error
v-container
.pt-5
v-layout(row)
v-flex(xs10)
a(href='/'): img(src='/svg/logo-wikijs.svg')
v-flex.text-right(xs2)
v-btn(href='/', depressed, color='red darken-3')
v-icon(left) home
span Home
v-alert(color='grey', outline, :value='true', icon='error')
strong.red--text.text--lighten-3 Oops, something went wrong...
.body-1.red--text.text--lighten-2= message
.app-error
a(href='/')
img(src='/svg/logo-wikijs.svg')
strong Oops, something went wrong...
span= message
if error.stack
v-expansion-panel.mt-5
v-expansion-panel-content.red.darken-3(:value='true')
div(slot='header') View Debug Trace
v-card(color='grey darken-4')
v-card-text
pre: code #{error.stack}
if error.stack
pre: code #{error.stack}
......@@ -32,7 +32,7 @@ html
link(
type='text/css'
rel='stylesheet'
href='https://use.fontawesome.com/releases/v5.9.0/css/all.css'
href='https://use.fontawesome.com/releases/v5.10.0/css/all.css'
)
else if config.theming.iconset === 'fa4'
link(
......
......@@ -36,7 +36,7 @@ html(lang=siteConfig.lang)
link(
type='text/css'
rel='stylesheet'
href='https://use.fontawesome.com/releases/v5.9.0/css/all.css'
href='https://use.fontawesome.com/releases/v5.10.0/css/all.css'
)
else if config.theming.iconset === 'fa4'
link(
......
This diff was suppressed by a .gitattributes entry.
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