feat: authentication improvements
This commit is contained in:
@@ -2,6 +2,13 @@ const passport = require('passport')
|
||||
const fs = require('fs-extra')
|
||||
const _ = require('lodash')
|
||||
const path = require('path')
|
||||
const NodeCache = require('node-cache')
|
||||
|
||||
const userCache = new NodeCache({
|
||||
stdTTL: 10,
|
||||
checkperiod: 600,
|
||||
deleteOnExpire: true
|
||||
})
|
||||
|
||||
/* global WIKI */
|
||||
|
||||
@@ -17,16 +24,22 @@ module.exports = {
|
||||
})
|
||||
|
||||
passport.deserializeUser(function (id, done) {
|
||||
WIKI.models.users.query().findById(id).then((user) => {
|
||||
if (user) {
|
||||
done(null, user)
|
||||
} else {
|
||||
done(new Error(WIKI.lang.t('auth:errors:usernotfound')), null)
|
||||
}
|
||||
return true
|
||||
}).catch((err) => {
|
||||
done(err, null)
|
||||
})
|
||||
const usr = userCache.get(id)
|
||||
if (usr) {
|
||||
done(null, usr)
|
||||
} else {
|
||||
WIKI.models.users.query().findById(id).then((user) => {
|
||||
if (user) {
|
||||
userCache.set(id, user)
|
||||
done(null, user)
|
||||
} else {
|
||||
done(new Error(WIKI.lang.t('auth:errors:usernotfound')), null)
|
||||
}
|
||||
return true
|
||||
}).catch((err) => {
|
||||
done(err, null)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return this
|
||||
|
@@ -47,6 +47,7 @@ module.exports = {
|
||||
* Post-Master Boot Sequence
|
||||
*/
|
||||
async postBootMaster() {
|
||||
await WIKI.models.authentication.refreshStrategiesFromDisk()
|
||||
await WIKI.auth.activateStrategies()
|
||||
await WIKI.models.storage.refreshTargetsFromDisk()
|
||||
await WIKI.queue.start()
|
||||
|
@@ -27,9 +27,7 @@ exports.up = knex => {
|
||||
.createTable('authentication', table => {
|
||||
table.increments('id').primary()
|
||||
table.string('key').notNullable().unique()
|
||||
table.string('title').notNullable()
|
||||
table.boolean('isEnabled').notNullable().defaultTo(false)
|
||||
table.boolean('useForm').notNullable().defaultTo(false)
|
||||
table.jsonb('config').notNullable()
|
||||
table.boolean('selfRegistration').notNullable().defaultTo(false)
|
||||
table.jsonb('domainWhitelist').notNullable()
|
||||
@@ -108,7 +106,6 @@ exports.up = knex => {
|
||||
.createTable('storage', table => {
|
||||
table.increments('id').primary()
|
||||
table.string('key').notNullable().unique()
|
||||
table.string('title').notNullable()
|
||||
table.boolean('isEnabled').notNullable().defaultTo(false)
|
||||
table.enum('mode', ['sync', 'push', 'pull']).notNullable().defaultTo('push')
|
||||
table.jsonb('config')
|
||||
|
@@ -17,12 +17,23 @@ module.exports = {
|
||||
AuthenticationQuery: {
|
||||
async strategies(obj, args, context, info) {
|
||||
let strategies = await WIKI.models.authentication.getStrategies()
|
||||
strategies = strategies.map(stg => ({
|
||||
...stg,
|
||||
config: _.sortBy(_.transform(stg.config, (res, value, key) => {
|
||||
res.push({ key, value: JSON.stringify(value) })
|
||||
}, []), 'key')
|
||||
}))
|
||||
strategies = strategies.map(stg => {
|
||||
const strategyInfo = _.find(WIKI.data.authentication, ['key', stg.key]) || {}
|
||||
return {
|
||||
...strategyInfo,
|
||||
...stg,
|
||||
config: _.sortBy(_.transform(stg.config, (res, value, key) => {
|
||||
const configData = _.get(strategyInfo.props, key, {})
|
||||
res.push({
|
||||
key,
|
||||
value: JSON.stringify({
|
||||
...configData,
|
||||
value
|
||||
})
|
||||
})
|
||||
}, []), 'key')
|
||||
}
|
||||
})
|
||||
if (args.filter) { strategies = graphHelper.filter(strategies, args.filter) }
|
||||
if (args.orderBy) { strategies = graphHelper.orderBy(strategies, args.orderBy) }
|
||||
return strategies
|
||||
|
@@ -15,8 +15,8 @@ module.exports = {
|
||||
let targets = await WIKI.models.storage.getTargets()
|
||||
targets = targets.map(tgt => {
|
||||
const targetInfo = _.find(WIKI.data.storage, ['key', tgt.key]) || {}
|
||||
console.info(targetInfo)
|
||||
return {
|
||||
...targetInfo,
|
||||
...tgt,
|
||||
config: _.sortBy(_.transform(tgt.config, (res, value, key) => {
|
||||
const configData = _.get(targetInfo.props, key, {})
|
||||
|
@@ -51,7 +51,10 @@ type AuthenticationStrategy {
|
||||
key: String!
|
||||
props: [String]
|
||||
title: String!
|
||||
description: String
|
||||
useForm: Boolean!
|
||||
logo: String
|
||||
website: String
|
||||
icon: String
|
||||
config: [KeyValuePair]
|
||||
selfRegistration: Boolean!
|
||||
|
@@ -1,3 +1,5 @@
|
||||
const _ = require('lodash')
|
||||
|
||||
module.exports = {
|
||||
/**
|
||||
* Get default value of type
|
||||
@@ -14,5 +16,23 @@ module.exports = {
|
||||
case 'boolean':
|
||||
return false
|
||||
}
|
||||
},
|
||||
parseModuleProps (props) {
|
||||
return _.transform(props, (result, value, key) => {
|
||||
let defaultValue = ''
|
||||
if (_.isPlainObject(value)) {
|
||||
defaultValue = !_.isNil(value.default) ? value.default : this.getTypeDefaultValue(value.type)
|
||||
} else {
|
||||
defaultValue = this.getTypeDefaultValue(value)
|
||||
}
|
||||
_.set(result, key, {
|
||||
default: defaultValue,
|
||||
type: (value.type || value).toLowerCase(),
|
||||
title: value.title || _.startCase(key),
|
||||
hint: value.hint || false,
|
||||
enum: value.enum || false
|
||||
})
|
||||
return result
|
||||
}, {})
|
||||
}
|
||||
}
|
||||
|
@@ -139,19 +139,6 @@ module.exports = async () => {
|
||||
|
||||
app.use('/', ctrl.auth)
|
||||
|
||||
// app.use('/graphql', (req, res, next) => {
|
||||
// graphqlApollo.graphqlExpress({
|
||||
// schema: graphqlSchema,
|
||||
// context: { req, res },
|
||||
// formatError: (err) => {
|
||||
// return {
|
||||
// message: err.message
|
||||
// }
|
||||
// }
|
||||
// })(req, res, next)
|
||||
// })
|
||||
// app.use('/graphiql', graphqlApollo.graphiqlExpress({ endpointURL: '/graphql' }))
|
||||
|
||||
app.use('/', mw.auth, ctrl.common)
|
||||
|
||||
// ----------------------------------------
|
||||
|
@@ -16,14 +16,12 @@ module.exports = class Authentication extends Model {
|
||||
static get jsonSchema () {
|
||||
return {
|
||||
type: 'object',
|
||||
required: ['key', 'title', 'isEnabled', 'useForm'],
|
||||
required: ['key', 'isEnabled'],
|
||||
|
||||
properties: {
|
||||
id: {type: 'integer'},
|
||||
key: {type: 'string'},
|
||||
title: {type: 'string'},
|
||||
isEnabled: {type: 'boolean'},
|
||||
useForm: {type: 'boolean'},
|
||||
config: {type: 'object'},
|
||||
selfRegistration: {type: 'boolean'},
|
||||
domainWhitelist: {type: 'object'},
|
||||
@@ -52,39 +50,37 @@ module.exports = class Authentication extends Model {
|
||||
const def = await fs.readFile(path.join(WIKI.SERVERPATH, 'modules/authentication', dir, 'definition.yml'), 'utf8')
|
||||
diskStrategies.push(yaml.safeLoad(def))
|
||||
}
|
||||
WIKI.data.authentication = diskStrategies.map(strategy => ({
|
||||
...strategy,
|
||||
props: commonHelper.parseModuleProps(strategy.props)
|
||||
}))
|
||||
|
||||
let newStrategies = []
|
||||
_.forEach(diskStrategies, strategy => {
|
||||
for (let strategy of WIKI.data.authentication) {
|
||||
if (!_.some(dbStrategies, ['key', strategy.key])) {
|
||||
newStrategies.push({
|
||||
key: strategy.key,
|
||||
title: strategy.title,
|
||||
isEnabled: false,
|
||||
useForm: strategy.useForm,
|
||||
config: _.transform(strategy.props, (result, value, key) => {
|
||||
if (_.isPlainObject(value)) {
|
||||
let cfgValue = {
|
||||
type: value.type.toLowerCase(),
|
||||
value: !_.isNil(value.default) ? value.default : commonHelper.getTypeDefaultValue(value.type)
|
||||
}
|
||||
if (_.isArray(value.enum)) {
|
||||
cfgValue.enum = value.enum
|
||||
}
|
||||
_.set(result, key, cfgValue)
|
||||
} else {
|
||||
_.set(result, key, {
|
||||
type: value.toLowerCase(),
|
||||
value: commonHelper.getTypeDefaultValue(value)
|
||||
})
|
||||
}
|
||||
_.set(result, key, value.default)
|
||||
return result
|
||||
}, {}),
|
||||
selfRegistration: false,
|
||||
domainWhitelist: { v: [] },
|
||||
autoEnrollGroups: { v: [] }
|
||||
})
|
||||
} else {
|
||||
const strategyConfig = _.get(_.find(dbStrategies, ['key', strategy.key]), 'config', {})
|
||||
await WIKI.models.authentication.query().patch({
|
||||
config: _.transform(strategy.props, (result, value, key) => {
|
||||
if (!_.has(result, key)) {
|
||||
_.set(result, key, value.default)
|
||||
}
|
||||
return result
|
||||
}, strategyConfig)
|
||||
}).where('key', strategy.key)
|
||||
}
|
||||
})
|
||||
}
|
||||
if (newStrategies.length > 0) {
|
||||
await WIKI.models.authentication.query().insert(newStrategies)
|
||||
WIKI.logger.info(`Loaded ${newStrategies.length} new authentication strategies: [ OK ]`)
|
||||
|
@@ -16,12 +16,11 @@ module.exports = class Storage extends Model {
|
||||
static get jsonSchema () {
|
||||
return {
|
||||
type: 'object',
|
||||
required: ['key', 'title', 'isEnabled'],
|
||||
required: ['key', 'isEnabled'],
|
||||
|
||||
properties: {
|
||||
id: {type: 'integer'},
|
||||
key: {type: 'string'},
|
||||
title: {type: 'string'},
|
||||
isEnabled: {type: 'boolean'},
|
||||
mode: {type: 'string'},
|
||||
config: {type: 'object'}
|
||||
@@ -46,22 +45,7 @@ module.exports = class Storage extends Model {
|
||||
}
|
||||
WIKI.data.storage = diskTargets.map(target => ({
|
||||
...target,
|
||||
props: _.transform(target.props, (result, value, key) => {
|
||||
let defaultValue = ''
|
||||
if (_.isPlainObject(value)) {
|
||||
defaultValue = !_.isNil(value.default) ? value.default : commonHelper.getTypeDefaultValue(value.type)
|
||||
} else {
|
||||
defaultValue = commonHelper.getTypeDefaultValue(value)
|
||||
}
|
||||
_.set(result, key, {
|
||||
default: defaultValue,
|
||||
type: (value.type || value).toLowerCase(),
|
||||
title: value.title || _.startCase(key),
|
||||
hint: value.hint || false,
|
||||
enum: value.enum || false
|
||||
})
|
||||
return result
|
||||
}, {})
|
||||
props: commonHelper.parseModuleProps(target.props)
|
||||
}))
|
||||
|
||||
// -> Insert new targets
|
||||
@@ -70,7 +54,6 @@ module.exports = class Storage extends Model {
|
||||
if (!_.some(dbTargets, ['key', target.key])) {
|
||||
newTargets.push({
|
||||
key: target.key,
|
||||
title: target.title,
|
||||
isEnabled: false,
|
||||
mode: 'push',
|
||||
config: _.transform(target.props, (result, value, key) => {
|
||||
@@ -81,7 +64,6 @@ module.exports = class Storage extends Model {
|
||||
} else {
|
||||
const targetConfig = _.get(_.find(dbTargets, ['key', target.key]), 'config', {})
|
||||
await WIKI.models.storage.query().patch({
|
||||
title: target.title,
|
||||
config: _.transform(target.props, (result, value, key) => {
|
||||
if (!_.has(result, key)) {
|
||||
_.set(result, key, value.default)
|
||||
|
@@ -1,6 +1,9 @@
|
||||
key: auth0
|
||||
title: Auth0
|
||||
description: Auth0 provides universal identity platform for web, mobile, IoT, and internal applications.
|
||||
author: requarks.io
|
||||
logo: https://static.requarks.io/logo/auth0.svg
|
||||
website: https://auth0.com/
|
||||
useForm: false
|
||||
props:
|
||||
domain: String
|
||||
|
@@ -1,6 +1,9 @@
|
||||
key: azure
|
||||
title: Azure Active Directory
|
||||
description: Azure Active Directory (Azure AD) is Microsoft’s multi-tenant, cloud-based directory, and identity management service that combines core directory services, application access management, and identity protection into a single solution.
|
||||
author: requarks.io
|
||||
logo: https://static.requarks.io/logo/azure.svg
|
||||
website: https://azure.microsoft.com/services/active-directory/
|
||||
useForm: false
|
||||
props:
|
||||
clientId: String
|
||||
|
@@ -1,6 +1,9 @@
|
||||
key: cas
|
||||
title: CAS
|
||||
description: The Central Authentication Service (CAS) is a single sign-on protocol for the web.
|
||||
author: requarks.io
|
||||
logo: https://static.requarks.io/logo/cas.svg
|
||||
website: https://wiki.js.org
|
||||
useForm: false
|
||||
props:
|
||||
ssoBaseURL: String
|
||||
|
@@ -1,6 +1,9 @@
|
||||
key: discord
|
||||
title: Discord
|
||||
description: Discord is a proprietary freeware VoIP application designed for gaming communities, that specializes in text, video and audio communication between users in a chat channel.
|
||||
author: requarks.io
|
||||
logo: https://static.requarks.io/logo/discord.svg
|
||||
website: https://discordapp.com/
|
||||
useForm: false
|
||||
props:
|
||||
clientId: String
|
||||
|
@@ -1,6 +1,9 @@
|
||||
key: dropbox
|
||||
title: Dropbox
|
||||
description: Dropbox is a file hosting service that offers cloud storage, file synchronization, personal cloud, and client software.
|
||||
author: requarks.io
|
||||
logo: https://static.requarks.io/logo/dropbox.svg
|
||||
website: https://dropbox.com
|
||||
useForm: false
|
||||
props:
|
||||
clientId: String
|
||||
|
@@ -1,6 +1,9 @@
|
||||
key: facebook
|
||||
title: Facebook
|
||||
description: Facebook is an online social media and social networking service company.
|
||||
author: requarks.io
|
||||
logo: https://static.requarks.io/logo/facebook.svg
|
||||
website: https://facebook.com/
|
||||
useForm: false
|
||||
props:
|
||||
clientId: String
|
||||
|
@@ -1,6 +1,9 @@
|
||||
key: github
|
||||
title: GitHub
|
||||
description: GitHub Inc. is a web-based hosting service for version control using Git.
|
||||
author: requarks.io
|
||||
logo: https://static.requarks.io/logo/github.svg
|
||||
website: https://github.com
|
||||
useForm: false
|
||||
props:
|
||||
clientId: String
|
||||
|
@@ -1,6 +1,9 @@
|
||||
key: google
|
||||
title: Google
|
||||
description: Google specializes in Internet-related services and products, which include online advertising technologies, search engine, cloud computing, software, and hardware.
|
||||
author: requarks.io
|
||||
logo: https://static.requarks.io/logo/google.svg
|
||||
website: https://console.developers.google.com/
|
||||
useForm: false
|
||||
props:
|
||||
clientId: String
|
||||
|
@@ -1,22 +1,36 @@
|
||||
key: ldap
|
||||
title: LDAP / Active Directory
|
||||
description: Active Directory is a directory service that Microsoft developed for the Windows domain networks.
|
||||
author: requarks.io
|
||||
logo: https://static.requarks.io/logo/active-directory.svg
|
||||
website: https://www.microsoft.com/windowsserver
|
||||
useForm: true
|
||||
props:
|
||||
url:
|
||||
title: URL
|
||||
type: String
|
||||
default: 'ldap://serverhost:389'
|
||||
hint: (e.g. ldap://serverhost:389)
|
||||
bindDn:
|
||||
title: Bind DN
|
||||
type: String
|
||||
default: cn='root'
|
||||
bindCredentials: String
|
||||
hint: The dstinguished name (dn) of the account used for binding.
|
||||
bindCredentials:
|
||||
type: String
|
||||
hint: The password of the account used for binding.
|
||||
searchBase:
|
||||
type: String
|
||||
default: 'o=users,o=example.com'
|
||||
searchFilter:
|
||||
type: String
|
||||
default: '(uid={{username}})'
|
||||
hint: The query to use to match username. {{username}} must be present.
|
||||
tlsEnabled:
|
||||
title: Use TLS
|
||||
type: Boolean
|
||||
default: false
|
||||
tlsCertPath: String
|
||||
tlsCertPath:
|
||||
title: TLS Certificate Path
|
||||
type: String
|
||||
hint: Absolute path to the TLS certificate on the server.
|
||||
|
@@ -1,5 +1,8 @@
|
||||
key: local
|
||||
title: Local
|
||||
description: Built-in authentication for Wiki.js
|
||||
author: requarks.io
|
||||
logo: https://static.requarks.io/logo/wikijs.svg
|
||||
website: https://wiki.js.org
|
||||
useForm: true
|
||||
props: {}
|
||||
|
@@ -1,6 +1,9 @@
|
||||
key: microsoft
|
||||
title: Microsoft Account
|
||||
title: Microsoft
|
||||
description: Microsoft is a software company, best known for it's Windows, Office, Azure, Xbox and Surface products.
|
||||
author: requarks.io
|
||||
logo: https://static.requarks.io/logo/microsoft.svg
|
||||
website: https://apps.dev.microsoft.com/
|
||||
useForm: false
|
||||
props:
|
||||
clientId: String
|
||||
|
@@ -1,6 +1,9 @@
|
||||
key: oauth2
|
||||
title: OAuth2
|
||||
title: Generic OAuth2
|
||||
description: OAuth 2 is an authorization framework that enables applications to obtain limited access to user accounts on an HTTP service.
|
||||
author: requarks.io
|
||||
logo: https://static.requarks.io/logo/oauth2.svg
|
||||
website: https://oauth.net/2/
|
||||
useForm: false
|
||||
props:
|
||||
clientId: String
|
||||
|
35
server/modules/authentication/oidc/authentication.js
Normal file
35
server/modules/authentication/oidc/authentication.js
Normal file
@@ -0,0 +1,35 @@
|
||||
const _ = require('lodash')
|
||||
|
||||
/* global WIKI */
|
||||
|
||||
// ------------------------------------
|
||||
// OpenID Connect Account
|
||||
// ------------------------------------
|
||||
|
||||
const OpenIDConnectStrategy = require('passport-openidconnect').Strategy
|
||||
|
||||
module.exports = {
|
||||
init (passport, conf) {
|
||||
passport.use('oidc',
|
||||
new OpenIDConnectStrategy({
|
||||
authorizationURL: conf.authorizationURL,
|
||||
tokenURL: conf.tokenURL,
|
||||
clientID: conf.clientId,
|
||||
clientSecret: conf.clientSecret,
|
||||
issuer: conf.issuer,
|
||||
callbackURL: conf.callbackURL
|
||||
}, (iss, sub, profile, jwtClaims, accessToken, refreshToken, params, cb) => {
|
||||
WIKI.models.users.processProfile({
|
||||
id: jwtClaims.sub,
|
||||
provider: 'oidc',
|
||||
email: _.get(jwtClaims, conf.emailClaim),
|
||||
name: _.get(jwtClaims, conf.usernameClaim)
|
||||
}).then((user) => {
|
||||
return cb(null, user) || true
|
||||
}).catch((err) => {
|
||||
return cb(err, null) || true
|
||||
})
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
16
server/modules/authentication/oidc/definition.yml
Normal file
16
server/modules/authentication/oidc/definition.yml
Normal file
@@ -0,0 +1,16 @@
|
||||
key: oidc
|
||||
title: Generic OpenID Connect
|
||||
description: OpenID Connect 1.0 is a simple identity layer on top of the OAuth 2.0 protocol.
|
||||
author: requarks.io
|
||||
logo: https://static.requarks.io/logo/oidc.svg
|
||||
website: http://openid.net/connect/
|
||||
useForm: false
|
||||
props:
|
||||
clientId: String
|
||||
clientSecret: String
|
||||
authorizationURL: String
|
||||
tokenURL: String
|
||||
issuer: String
|
||||
userInfoUrl: String
|
||||
emailClaim: String
|
||||
usernameClaim: String
|
29
server/modules/authentication/okta/authentication.js
Normal file
29
server/modules/authentication/okta/authentication.js
Normal file
@@ -0,0 +1,29 @@
|
||||
/* global WIKI */
|
||||
|
||||
// ------------------------------------
|
||||
// Okta Account
|
||||
// ------------------------------------
|
||||
|
||||
const OktaStrategy = require('passport-okta-oauth').Strategy
|
||||
|
||||
module.exports = {
|
||||
init (passport, conf) {
|
||||
passport.use('okta',
|
||||
new OktaStrategy({
|
||||
audience: conf.audience,
|
||||
clientID: conf.clientId,
|
||||
clientSecret: conf.clientSecret,
|
||||
idp: conf.idp,
|
||||
callbackURL: conf.callbackURL,
|
||||
response_type: 'code',
|
||||
scope: ['openid', 'email', 'profile']
|
||||
}, (accessToken, refreshToken, profile, cb) => {
|
||||
WIKI.models.users.processProfile(profile).then((user) => {
|
||||
return cb(null, user) || true
|
||||
}).catch((err) => {
|
||||
return cb(err, null) || true
|
||||
})
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
21
server/modules/authentication/okta/definition.yml
Normal file
21
server/modules/authentication/okta/definition.yml
Normal file
@@ -0,0 +1,21 @@
|
||||
key: okta
|
||||
title: Okta
|
||||
description: Okta provide secure identity management and single sign-on to any application.
|
||||
author: requarks.io
|
||||
logo: https://static.requarks.io/logo/okta.svg
|
||||
website: https://www.okta.com/
|
||||
useForm: false
|
||||
props:
|
||||
clientId:
|
||||
type: String
|
||||
hint: 20 chars alphanumeric string
|
||||
clientSecret:
|
||||
type: String
|
||||
hint: 40 chars alphanumeric string with a hyphen(s)
|
||||
idp:
|
||||
title: Identity Provider ID (idp)
|
||||
type: String
|
||||
hint: (optional) 20 chars alphanumeric string
|
||||
audience:
|
||||
type: String
|
||||
hint: Okta domain (e.g. https://example.okta.com, https://example.oktapreview.com)
|
@@ -1,6 +1,9 @@
|
||||
key: slack
|
||||
title: Slack
|
||||
description: Slack is a cloud-based set of proprietary team collaboration tools and services.
|
||||
author: requarks.io
|
||||
logo: https://static.requarks.io/logo/slack.svg
|
||||
website: https://api.slack.com/docs/oauth
|
||||
useForm: false
|
||||
props:
|
||||
clientId: String
|
||||
|
@@ -1,6 +1,9 @@
|
||||
key: twitch
|
||||
title: Twitch
|
||||
description: Twitch is a live streaming video platform.
|
||||
author: requarks.io
|
||||
logo: https://static.requarks.io/logo/twitch.svg
|
||||
website: https://dev.twitch.tv/docs/authentication/
|
||||
useForm: false
|
||||
props:
|
||||
clientId: String
|
||||
|
@@ -4,7 +4,7 @@ block body
|
||||
#app.is-fullscreen
|
||||
v-app
|
||||
.onboarding
|
||||
img.animated.zoomIn(src='/svg/logo-wikijs.svg', alt='Wiki.js')
|
||||
img.animated.fadeIn(src='/svg/logo-wikijs.svg', alt='Wiki.js')
|
||||
.headline= t('welcome.title')
|
||||
.subheading.mt-3= t('welcome.subtitle')
|
||||
v-btn.mt-5(color='primary', href='/e/home', large)
|
||||
|
Reference in New Issue
Block a user