Start client-side locale support

feature/cd
garrettmills 4 years ago
parent d2ae9c43e8
commit c956628c53
No known key found for this signature in database
GPG Key ID: 6ACD58D6ADACFC6E

@ -7,13 +7,13 @@ const template = `
<div class="coreid-auth-page-inner">
<div class="coreid-header font-weight-light">{{ app_name }}</div>
<div class="coreid-message">
Your account has multi-factor authentication enabled. Please enter the code generated by your authenticator app to continue.
{{ t['mfa.challenge_prompt'] }}
</div>
<div class="form-group">
<input
class="form-control"
type="number"
placeholder="2FA Code"
:placeholder="t['mfa.mfa_code']"
v-model="verify_code"
@keyup="on_key_up"
name="verify_code"
@ -28,7 +28,7 @@ const template = `
<div class="coreid-loading-spinner" v-if="loading"><div class="inner"></div></div>
<small
class="mr-3"
><a href="#" class="text-secondary" @click="on_do_recovery">Lost your MFA device?</a></small>
><a href="#" class="text-secondary" @click="on_do_recovery">{{ t['mfa.lost_device'] }}</a></small>
</div>
</div>
`
@ -45,6 +45,19 @@ export default class MFAChallengePage extends Component {
error_message = ''
other_message = ''
t = {}
async vue_on_create() {
this.t = await T(
'mfa.challenge_prompt',
'mfa.mfa_code',
'mfa.lost_device',
'mfa.invalid_code',
'mfa.success'
)
console.log(this)
}
async watch_verify_code(new_verify_code, old_verify_code) {
if ( new_verify_code.length === 6 ) {
@ -53,11 +66,11 @@ export default class MFAChallengePage extends Component {
this.verify_success = result && result.is_valid
if ( !this.verify_success ) {
this.other_message = ''
this.error_message = `Uh, oh! It looks like that's not the right code. Please try again.`
this.error_message = this.t['mfa.invalid_code']
this.loading = false
} else {
this.error_message = ''
this.other_message = `Success! Redirecting...`
this.other_message = this.t['mfa.success']
await location_service.redirect(result.next_destination, 1500)
}
} else if ( new_verify_code.length > 6 ) {

@ -9,17 +9,13 @@ const template = `
<div class="coreid-header font-weight-light">{{ app_name }}</div>
<span v-if="step === 0">
<div class="coreid-message">
This process will disable multi-factor authentication on your account.
<br><br>
For security reasons, this will sign you out of all devices. It will also deactivate any existing app passwords you have generated.
<br><br>
Are you sure you want to continue?
<span v-html="t['mfa.disable_prompt']"></span>
</div>
<div v-if="error_message" class="error-message">{{ error_message }}</div>
<div v-if="other_message" class="other-message">{{ other_message }}</div>
<div class="buttons text-right pad-top">
<button type="button" class="btn btn-primary" @click="back_click">Cancel</button>
<button type="button" class="btn btn-primary" @click="continue_click">Disable MFA</button>
<button type="button" class="btn btn-primary" @click="back_click">{{ t['common.cancel'] }}</button>
<button type="button" class="btn btn-primary" @click="continue_click">{{ t['mfa.disable'] }}</button>
</div>
</span>
<div class="coreid-loading-spinner" v-if="loading"><div class="inner"></div></div>
@ -37,9 +33,18 @@ export default class MFADisableComponent extends Component {
loading = false
error_message = ''
other_message = ''
t = {}
vue_on_create() {
async vue_on_create() {
this.app_name = session.get('app.name')
this.t = await T(
'common.cancel',
'common.unknown_error',
'mfa.disable_success',
'mfa.disable',
'mfa.disable_prompt'
)
}
async back_click() {
@ -51,10 +56,10 @@ export default class MFADisableComponent extends Component {
this.loading = true
const success = await auth_api.mfa_disable()
if ( success ) {
this.other_message = 'MFA was successfully disabled. You\'ll now sign-in normally.'
this.other_message = this.t['mfa.disable_success']
await location_service.redirect('/dash/profile', 3000)
} else {
this.error_message = 'An unknown error occurred while trying to disable MFA. Let\'s try again...'
this.error_message = this.t['common.unknown_error']
await location_service.reload(4000)
}
}

@ -7,13 +7,13 @@ const template = `
<div class="coreid-auth-page-inner">
<div class="coreid-header font-weight-light">{{ app_name }}</div>
<div class="coreid-message">
To recover access to your account, you can enter one of the generated MFA recovery codes:
{{ t['mfa.recover_prompt'] }}
</div>
<div class="form-group">
<input
class="form-control"
type="text"
placeholder="Recovery Code"
:placeholder="t['mfa.recovery_code']"
v-model="recovery_code"
@keyup="on_key_up"
name="recovery_code"
@ -27,7 +27,7 @@ const template = `
<div v-if="other_message" class="other-message">{{ other_message }}</div>
<small
class="mr-3" v-if="!loading"
><a href="#" class="text-secondary" @click="on_do_challenge">Have a normal MFA code?</a></small>
><a href="#" class="text-secondary" @click="on_do_challenge">{{ t['mfa.normal_code'] }}</a></small>
<div class="coreid-loading-spinner" v-if="loading"><div class="inner"></div></div>
</div>
</div>
@ -43,8 +43,17 @@ export default class MFARecoveryComponent extends Component {
recovery_code = ''
error_message = ''
other_message = ''
t = {}
async vue_on_create() {
this.t = await T(
'mfa.recover_prompt',
'mfa.recovery_code',
'mfa.normal_code',
'mfa.recover_success',
'mfa.invalid_code'
)
this.$nextTick(() => {
this.$refs.verify_input.focus()
})
@ -57,11 +66,11 @@ export default class MFARecoveryComponent extends Component {
this.loading = true
const result = await auth_api.attempt_mfa_recovery(this.recovery_code)
if ( result && result.success ) {
this.other_message = `Success! There are only ${result.remaining_codes} recovery codes remaining. Let's get you on your way...`
this.other_message = this.t['mfa.recover_success'].replace('NUM_CODES', result.remaining_codes)
await location_service.redirect(result.next_destination, 5000)
} else {
this.loading = false
this.error_message = 'Hm. It doesn\'t look like that code is valid.'
this.error_message = this.t['mfa.invalid_code']
}
}
}

@ -16,7 +16,7 @@ const template = `
id="coreid-login-form-username"
name="username"
class="form-control"
placeholder="Username"
:placeholder="t['login.username']"
v-model="username"
autofocus
@keyup="on_key_up"
@ -29,7 +29,7 @@ const template = `
id="coreid-login-form-password"
name="password"
class="form-control"
placeholder="Password"
:placeholder="t['login.password']"
v-model="password"
:disabled="loading"
@keyup="on_key_up"
@ -42,12 +42,12 @@ const template = `
<small
class="mr-3"
v-if="!step_two && !loading && registration_enabled"
><a href="#" class="text-secondary" @click="on_register_click">Need an account?</a></small>
><a href="#" class="text-secondary" @click="on_register_click">{{ t['login.need_account'] }}</a></small>
<small
class="mr-3"
v-if="!auth_user && !loading"
><a href="#" class="text-secondary" @click="on_forgot_password">Forgot password?</a></small>
<button type="button" class="btn btn-primary" :disabled="loading" v-if="step_two" v-on:click="back_click">Back</button>
><a href="#" class="text-secondary" @click="on_forgot_password">{{ t['login.forgot_password'] }}</a></small>
<button type="button" class="btn btn-primary" :disabled="loading" v-if="step_two" v-on:click="back_click">{{ t['common.back'] }}</button>
<button type="button" class="btn btn-primary" :disabled="loading || btn_disabled" v-on:click="step_click">{{ button_text }}</button>
</div>
</form>
@ -65,7 +65,7 @@ export default class AuthLoginForm extends Component {
username = ''
password = ''
button_text = 'Next'
button_text = ''
step_two = false
btn_disabled = true
loading = false
@ -74,6 +74,8 @@ export default class AuthLoginForm extends Component {
allow_back = true
auth_user = false
t = {}
watch_username(new_username, old_username) {
this.btn_disabled = !new_username
}
@ -81,7 +83,7 @@ export default class AuthLoginForm extends Component {
back_click() {
if ( !this.allow_back ) return;
this.step_two = false
this.button_text = 'Next'
this.button_text = this.t['common.next']
}
async vue_on_create() {
@ -92,6 +94,28 @@ export default class AuthLoginForm extends Component {
this.username = auth_user
await this.step_click()
}
// Batch-load translations all at once to make fewer requests
this.t = await T(
'common.continue',
'common.unknown_error',
'common.cancel',
'common.request',
'common.back',
'common.next',
'login.username',
'login.password',
'login.need_account',
'login.forgot_password',
'login.username_invalid',
'login.reset_password',
'login.reset_password_prompt',
'login.reset_password_success',
'login.success',
'login.get_started'
)
this.button_text = this.t['common.next']
}
async on_key_up(event) {
@ -110,17 +134,17 @@ export default class AuthLoginForm extends Component {
try {
const is_valid = await auth_api.validate_username(this.username)
if ( !is_valid ) {
this.error_message = 'That username is invalid. Please try again.'
this.error_message = this.t['login.username_invalid']
} else {
this.step_two = true
this.button_text = 'Continue'
this.button_text = this.t['common.continue']
this.error_message = ''
this.$nextTick(() => {
this.$refs.password_input.focus()
})
}
} catch (e) {
this.error_message = 'Sorry, an unknown error has occurred and we are unable to continue at this time.'
this.error_message = this.t['common.unknown_error']
}
this.loading = false
} else {
@ -139,27 +163,27 @@ export default class AuthLoginForm extends Component {
const result = await auth_api.attempt(attempt_vars)
if ( !result.success ) {
this.error_message = result.message || 'Sorry, an unknown error has occurred and we are unable to continue at this time.'
this.error_message = result.message || this.t['common.unknown_error']
this.loading = false
return
}
this.other_message = 'Success! Let\'s get you on your way...'
this.other_message = this.t['login.success']
await location_service.redirect(result.next, 1500)
}
}
on_register_click() {
this.loading = true
this.other_message = 'Okay! Let\'s get started...'
this.other_message = this.t['login.get_started']
location_service.redirect('/auth/register', 1500) // TODO get this dynamically
}
async on_forgot_password() {
console.log(message_service)
await message_service.modal({
title: 'Reset Password',
message: 'If you have forgotten your password, you can request a reset e-mail to be sent to your account. Enter your e-mail address:',
title: this.t['login.reset_password'],
message: this.t['login.reset_password_prompt'],
inputs: [
{
type: 'email',
@ -170,17 +194,17 @@ export default class AuthLoginForm extends Component {
buttons: [
{
type: 'close',
text: 'Cancel',
text: this.t['common.cancel'],
},
{
type: 'close',
class: ['btn', 'btn-primary'],
text: 'Request',
text: this.t['common.request'],
on_click: async ($event, { email }) => {
await password_service.request_reset(email)
await message_service.alert({
type: 'success',
message: 'Success! If that e-mail address is associated with a valid ' + this.app_name + ' account, it will receive an e-mail with more instructions shortly.',
message: this.t['login.reset_password_success'].replace('APP_NAME', this.app_name),
})
},
},

@ -15,7 +15,7 @@ const template = `
id="coreid-registration-form-first"
name="first_name"
class="form-control"
placeholder="First Name"
:placeholder="t['register.first_name']"
v-model="first_name"
autofocus
@keyup="on_key_up"
@ -29,7 +29,7 @@ const template = `
id="coreid-registration-form-last"
name="last_name"
class="form-control"
placeholder="Last Name"
:placeholder="t['register.last_name']"
v-model="last_name"
autofocus
@keyup="on_key_up"
@ -43,7 +43,7 @@ const template = `
id="coreid-registration-form-username"
name="username"
class="form-control"
placeholder="Username"
:placeholder="t['login.username']"
v-model="username"
autofocus
@keyup="on_key_up"
@ -58,7 +58,7 @@ const template = `
id="coreid-registration-form-email"
name="email"
class="form-control"
placeholder="E-Mail"
:placeholder="t['register.email']"
v-model="email"
autofocus
@keyup="on_key_up"
@ -72,14 +72,14 @@ const template = `
<small
class="mr-3"
v-if="step === 1 && !loading"
><a href="#" class="text-secondary" @click="on_login_click">Already have an account?</a></small>
><a href="#" class="text-secondary" @click="on_login_click">{{ t['register.have_account'] }}</a></small>
<button
type="button"
class="btn btn-primary"
:disabled="loading"
v-if="step > 1"
v-on:click="back_click"
>Back</button>
>{{ t['common.back'] }}</button>
<button
type="button"
class="btn btn-primary"
@ -104,15 +104,37 @@ export default class RegistrationFormComponent extends Component {
error_message = ''
message = ''
btn_disabled = true
button_text = 'Continue'
button_text = ''
first_name = ''
last_name = ''
username = ''
email = ''
t = {}
async vue_on_create() {
this.message = 'Create an account to continue:'
// Batch-load translated phrases
this.t = await T(
'common.back',
'common.continue',
'common.unknown_error',
'login.username',
'register.first_name',
'register.last_name',
'register.email',
'register.have_account',
'register.create_to_continue',
'register.provide_first_last',
'register.hi_choose_username',
'register.username_in_use',
'register.invalid_email',
'register.email_in_use',
'register.success_password',
'register.login_instead'
)
this.button_text = this.t['common.continue']
this.message = this.t['register.create_to_continue']
}
async step_click() {
@ -121,18 +143,18 @@ export default class RegistrationFormComponent extends Component {
this.other_message = ''
if ( this.step === 1 ) {
if ( !this.first_name.trim() || !this.last_name.trim() ) {
this.error_message = 'Please provide your first and last name.'
this.error_message = this.t['register.provide_first_last']
this.loading = false
return
}
this.message = `Hi, ${this.first_name.trim()}. Now, you need to choose a username:`
this.message = this.t['register.hi_choose_username'].replace('FIRST_NAME', this.first_name.trim())
this.$nextTick(() => {
this.$refs.input_username.focus()
})
} else if ( this.step === 2 ) {
if ( await auth_api.username_taken(this.username) ) {
this.error_message = 'That username is already taken.'
this.error_message = this.t['register.username_in_use']
this.loading = false
return
}
@ -144,13 +166,13 @@ export default class RegistrationFormComponent extends Component {
})
} else if ( this.step === 3 ) {
if ( !(await auth_api.validate_email(this.email)) ) {
this.error_message = 'Please provide a valid e-mail address.'
this.error_message = this.t['register.invalid_email']
this.loading = false
return
}
if ( await auth_api.email_taken(this.email) ) {
this.error_message = 'That e-mail address is already taken.'
this.error_message = this.t['register.email_in_use']
this.loading = false
return
}
@ -163,12 +185,12 @@ export default class RegistrationFormComponent extends Component {
email: this.email,
})
if ( !user ) this.error_message = 'Sorry, an unknown error has occurred and we are unable to continue at this time.'
else this.other_message = 'Welcome! Let\'s get your password set up...'
if ( !user ) this.error_message = this.t['common.unknown_error']
else this.other_message = this.t['register.success_password']
this.btn_disabled = true
return location_service.redirect('/dash', 2000)
} catch (e) {
this.error_message = e.message || 'Sorry, an unknown error has occurred and we are unable to continue at this time.'
this.error_message = e.message || this.t['common.unknown_error']
this.loading = false
return
}
@ -186,7 +208,7 @@ export default class RegistrationFormComponent extends Component {
this.on_key_up()
if ( this.step === 1 ) {
this.message = 'Create an account to continue:'
this.message = this.t['register.create_to_continue']
}
}
@ -209,7 +231,7 @@ export default class RegistrationFormComponent extends Component {
on_login_click() {
this.loading = true
this.other_message = 'Okay! We\'ll have you login instead...'
this.other_message = this.t['register.login_instead']
location_service.redirect('/auth/login', 1500)
}

@ -9,6 +9,9 @@ import RegistrationFormComponent from './auth/register/Form.component.js'
import MessageContainerComponent from './dash/message/MessageContainer.component.js'
import MFARecoveryComponent from './auth/MFARecovery.component.js'
// Create the global
import { T } from './service/Translate.service.js'
const components = {
AuthLoginForm,
AuthPage,

@ -0,0 +1,57 @@
class TranslateService {
_cache = {}
check_cache(...keys) {
const obj = {}
for ( const key of keys ) {
if ( this._cache[key] )
obj[key] = this._cache[key]
else return false
}
return obj
}
async resolve(key) {
const cache_hit = this.check_cache(key)
if ( cache_hit ) return cache_hit[key]
const result = await axios.get('/api/v1/locale/resolve/'+key)
if ( result && result.data && result.data.data ) {
this._cache[key] = result.data.data
return result.data.data
}
}
async load_module(key) {
const result = await axios.get('/api/v1/locale/module/'+key)
if ( result && result.data && result.data.data ) {
for ( const key in result.data.data ) {
if ( !this._cache[key] ) this._cache[key] = result.data.data[key]
}
return result.data.data
}
}
async batch_resolve(...keys) {
const cache_hit = this.check_cache(...keys)
if ( cache_hit ) return cache_hit
const result = await axios.post('/api/v1/locale/batch', { resolvers: keys })
if ( result && result.data && result.data.data ) {
for ( const key in result.data.data ) {
if ( !this._cache[key] ) this._cache[key] = result.data.data[key]
}
return result.data.data
}
}
}
const translate_service = new TranslateService()
const T = (...keys) => {
if ( keys.length === 1 ) return translate_service.resolve(keys[0])
else return translate_service.batch_resolve(...keys)
}
window.T = T
export { translate_service, T }

@ -0,0 +1,44 @@
const { Controller } = require('libflitter')
class LocaleController extends Controller {
static get services() {
return [...super.services, 'locale']
}
async resolve(req, res, next) {
try {
return res.api(req.T(req.params.resolver))
} catch (e) {
return res.status(400)
.message(req.T('common.invalid_resolver'))
.api()
}
}
async load_module(req, res, next) {
const resolver = `${req.i18n.region()}:${req.params.resolver}`
try {
return res.api(this.locale.get(resolver))
} catch (e) {
return res.status(400)
.message(req.T('common.invalid_resolver'))
.api()
}
}
async batch_resolve(req, res, next) {
if ( !req.body.resolvers || !Array.isArray(req.body.resolvers) )
return res.status(400)
.message(`${req.T('api.improper_field')} resolvers ${req.T('api.array')}`)
.api()
const translation_map = {}
for ( const resolve of req.body.resolvers ) {
translation_map[resolve] = req.T(resolve)
}
return res.api(translation_map)
}
}
module.exports = exports = LocaleController

@ -0,0 +1,22 @@
const locale_routes = {
prefix: '/api/v1/locale',
middleware: [],
get: {
'/resolve/:resolver': [
'controller::api:v1:Locale.resolve'
],
'/module/:resolver': [
'controller::api:v1:Locale.load_module',
],
},
post: {
'/batch': [
'controller::api:v1:Locale.batch_resolve',
],
},
}
module.exports = exports = locale_routes

@ -7,6 +7,7 @@ const traps_config = {
'/password/reset',
'/api/v1/password/resets',
'/auth/logout',
'/api/v1/locale/batch',
],
},
mfa_challenge: {
@ -17,6 +18,7 @@ const traps_config = {
'/api/v1/auth/mfa/attempt',
'/auth/logout',
'/api/v1/auth/mfa/recovery/attempt',
'/api/v1/locale/batch',
],
},
},

@ -10,4 +10,12 @@ module.exports = exports = {
deny: 'Deny',
grant: 'Grant Access',
back: 'Back',
next: 'Next',
cancel: 'Cancel',
request: 'Request',
continue: 'Continue',
unknown_error: 'An unknown error has occurred, and we are unable to continue at this time.',
invalid_resolver: 'Invalid locale resolver.',
}

@ -0,0 +1,12 @@
module.exports = exports = {
username: 'Username',
password: 'Password',
need_account: 'Need an account?',
forgot_password: 'Forgot password?',
username_invalid: 'That username is invalid. Please try again.',
success: 'Success! Let\'s get you on your way...',
get_started: 'Okay! Let\'s get you started...',
reset_password: 'Reset Password',
reset_password_prompt: 'If you have forgotten your password, you can request a reset e-mail to be sent to your account. Enter your e-mail address:',
reset_password_success: 'Success! If that e-mail address is associated with a valid APP_NAME account, it will receive an e-mail with more instructions shortly.',
}

@ -0,0 +1,23 @@
module.exports = exports = {
challenge_prompt: 'Your account has multi-factor authentication enabled. Please enter the code generated by your authenticator app to continue.',
mfa_code: { one: 'MFA Code', many: 'MFA Codes' },
lost_device: 'Lost your MFA device?',
invalid_code: 'Uh, oh! It looks like that\'s not the right code. Please try again.',
success: 'Success! Redirecting...',
disable_prompt: `
This process will disable multi-factor authentication on your account.
<br><br>
For security reasons, this will sign you out of all devices. It will also deactivate any existing app passwords you have generated.
<br><br>
Are you sure you want to continue?
`,
disable: 'Disable MFA',
disable_success: 'MFA was successfully disabled. You\'ll now sign-in normally.',
recover_prompt: 'To recover access to your account, you can enter one of the generated MFA recovery codes:',
recovery_code: 'Recovery Code',
normal_code: 'Have a normal MFA code?',
recover_success: 'Success! There are only NUM_CODES recovery codes remaining. Let\'s get you on your way...',
}

@ -0,0 +1,14 @@
module.exports = exports = {
first_name: 'First Name',
last_name: 'Last Name',
email: 'E-Mail Address',
have_account: 'Have an account?',
create_to_continue: 'Create an account to continue:',
provide_first_last: 'Please provide your first and last name.',
hi_choose_username: 'Hi, FIRST_NAME. Now, you need to choose a username:',
username_in_use: 'That username is already in use.',
invalid_email: 'Please provide a valid e-mail address.',
email_in_use: 'That e-mail address is already taken.',
success_password: 'Welcome! Let\'s get your password set up...',
login_instead: 'Okay! We\'ll have you login instead...',
}

@ -23,7 +23,7 @@
"flitter-di": "^0.5.0",
"flitter-flap": "^0.5.2",
"flitter-forms": "^0.8.1",
"flitter-i18n": "^0.1.0",
"flitter-i18n": "^0.1.1",
"flitter-jobs": "^0.1.2",
"flitter-less": "^0.5.3",
"flitter-orm": "^0.4.0",
@ -32,7 +32,7 @@
"ioredis": "^4.17.1",
"is-absolute-url": "^3.0.3",
"ldapjs": "^1.0.2",
"libflitter": "^0.53.0",
"libflitter": "^0.53.1",
"moment": "^2.24.0",
"mongodb": "^3.5.6",
"nodemailer": "^6.4.6",

@ -1888,10 +1888,10 @@ flitter-forms@^0.8.1:
recursive-readdir "^2.2.2"
validator "^10.11.0"
flitter-i18n@^0.1.0:
version "0.1.0"
resolved "https://registry.yarnpkg.com/flitter-i18n/-/flitter-i18n-0.1.0.tgz#9670f9ebf5f120e794f147ebeb1fe99537ac4779"
integrity sha512-vdEgcDNtA4EEjwJ0QqIIdPwR4FI67XMr4dD5U2l/u/xrnwzUrQD2O3avc5EK4RAPXTgZpK5twotTD6julJlwoA==
flitter-i18n@^0.1.1:
version "0.1.1"
resolved "https://registry.yarnpkg.com/flitter-i18n/-/flitter-i18n-0.1.1.tgz#852f916fc643e47c355fcef63d3761edf5bf4f22"
integrity sha512-n1z0Ijs5p98osooC6jdegbnPf1rgKM7ufeo1uqBKXYFrtIfZHJ9X/+QFUPvcKtchnvWk8m7UkWxXE85GLLeIig==
dependencies:
ncp "^2.0.0"
pluralize "^8.0.0"
@ -2833,10 +2833,10 @@ leven@^1.0.2:
resolved "https://registry.yarnpkg.com/leven/-/leven-1.0.2.tgz#9144b6eebca5f1d0680169f1a6770dcea60b75c3"
integrity sha1-kUS27ryl8dBoAWnxpncNzqYLdcM=
libflitter@^0.53.0:
version "0.53.0"
resolved "https://registry.yarnpkg.com/libflitter/-/libflitter-0.53.0.tgz#f1e2250597916dd7b7b7b8f0af04118ed1f30b41"
integrity sha512-i8otSTzNwMFJEa585bw+xfaNSuXm6c/kQBYXsyB8jzFU9PBFmvrEp/QzRqQD/lDgiQChYgjRs2TJofqDYvAOHg==
libflitter@^0.53.1:
version "0.53.1"
resolved "https://registry.yarnpkg.com/libflitter/-/libflitter-0.53.1.tgz#30b1838763a228fba8b9c820d2cad501c3aa0117"
integrity sha512-EK3okZyt0pmnpsZNx2lYOIcwgtmSOEPh4a5xE3pXM9RVc3dtXXscgJ5h9OvLTIN9WfRc7T5VTdpOjeAK6Xmysg==
dependencies:
colors "^1.3.3"
connect-mongodb-session "^2.2.0"

Loading…
Cancel
Save