Setup Wizard - Admin create + config write
This commit is contained in:
		| @@ -1 +1 @@ | ||||
| "use strict";Vue.use(VeeValidate,{enableAutoClasses:!0,classNames:{touched:"is-touched",untouched:"is-untouched",valid:"is-valid",invalid:"is-invalid",pristine:"is-pristine",dirty:"is-dirty"}}),jQuery(document).ready(function(t){new Vue({el:"main",data:{loading:!1,state:"welcome",syscheck:{ok:!1,error:"",results:[]},dbcheck:{ok:!1,error:""},gitcheck:{ok:!1,error:""},final:{ok:!1,error:"",results:[]},conf:{title:"Wiki",host:"http://",port:80,lang:"en",db:"mongodb://localhost:27017/wiki",pathData:"./data",pathRepo:"./repo",gitUrl:"",gitBranch:"master",gitAuthType:"ssh",gitAuthSSHKey:"",gitAuthUser:"",gitAuthPass:"",gitAuthSSL:!0,gitSignatureName:"",gitSignatureEmail:"",adminEmail:"",adminPassword:"",adminPasswordConfirm:""},considerations:{https:!1,port:!1,localhost:!1}},computed:{currentProgress:function(){var t="0%";switch(this.state){case"welcome":t="0%";break;case"syscheck":t=this.syscheck.ok?"15%":"5%";break;case"general":t="20%";break;case"considerations":t="30%";break;case"db":t="35%";break;case"dbcheck":t=this.dbcheck.ok?"50%":"40%";break;case"paths":t="55%";break;case"git":t="60%";break;case"gitcheck":t=this.gitcheck.ok?"75%":"65%";break;case"admin":t="80%"}return t}},methods:{proceedToWelcome:function(t){this.state="welcome",this.loading=!1},proceedToSyscheck:function(t){var e=this;this.state="syscheck",this.loading=!0,e.syscheck={ok:!1,error:"",results:[]},_.delay(function(){axios.post("/syscheck").then(function(t){t.data.ok===!0?(e.syscheck.ok=!0,e.syscheck.results=t.data.results):(e.syscheck.ok=!1,e.syscheck.error=t.data.error),e.loading=!1,e.$nextTick()}).catch(function(t){window.alert(t.message)})},1e3)},proceedToGeneral:function(t){var e=this;e.state="general",e.loading=!1,e.$nextTick(function(){e.$validator.validateAll("general")})},proceedToConsiderations:function(t){this.considerations={https:!_.startsWith(this.conf.host,"https"),port:!1,localhost:_.includes(this.conf.host,"localhost")},this.state="considerations",this.loading=!1},proceedToDb:function(t){var e=this;e.state="db",e.loading=!1,e.$nextTick(function(){e.$validator.validateAll("db")})},proceedToDbcheck:function(t){var e=this;this.state="dbcheck",this.loading=!0,e.dbcheck={ok:!1,error:""},_.delay(function(){axios.post("/dbcheck",{db:e.conf.db}).then(function(t){t.data.ok===!0?e.dbcheck.ok=!0:(e.dbcheck.ok=!1,e.dbcheck.error=t.data.error),e.loading=!1,e.$nextTick()}).catch(function(t){window.alert(t.message)})},1e3)},proceedToPaths:function(t){var e=this;e.state="paths",e.loading=!1,e.$nextTick(function(){e.$validator.validateAll("paths")})},proceedToGit:function(t){var e=this;e.state="git",e.loading=!1,e.$nextTick(function(){e.$validator.validateAll("git")})},proceedToGitCheck:function(t){var e=this;this.state="gitcheck",this.loading=!0,e.gitcheck={ok:!1,results:[],error:""},_.delay(function(){axios.post("/gitcheck",e.conf).then(function(t){t.data.ok===!0?(e.gitcheck.ok=!0,e.gitcheck.results=t.data.results):(e.gitcheck.ok=!1,e.gitcheck.error=t.data.error),e.loading=!1,e.$nextTick()}).catch(function(t){window.alert(t.message)})},1e3)},proceedToAdmin:function(t){var e=this;e.state="admin",e.loading=!1,e.$nextTick(function(){e.$validator.validateAll("admin")})},proceedToFinal:function(t){var e=this;e.state="final",e.loading=!0,e.final={ok:!1,error:"",results:[]},_.delay(function(){axios.post("/finalize",e.conf).then(function(t){t.data.ok===!0?(e.final.ok=!0,e.final.results=t.data.results):(e.final.ok=!1,e.final.error=t.data.error),e.loading=!1,e.$nextTick()}).catch(function(t){window.alert(t.message)})},1e3)},finish:function(t){}}})}); | ||||
| "use strict";Vue.use(VeeValidate,{enableAutoClasses:!0,classNames:{touched:"is-touched",untouched:"is-untouched",valid:"is-valid",invalid:"is-invalid",pristine:"is-pristine",dirty:"is-dirty"}}),jQuery(document).ready(function(t){new Vue({el:"main",data:{loading:!1,state:"welcome",syscheck:{ok:!1,error:"",results:[]},dbcheck:{ok:!1,error:""},gitcheck:{ok:!1,error:""},final:{ok:!1,error:"",results:[]},conf:{title:"Wiki",host:"http://",port:80,lang:"en",db:"mongodb://localhost:27017/wiki",pathData:"./data",pathRepo:"./repo",gitUseRemote:!0,gitUrl:"",gitBranch:"master",gitAuthType:"ssh",gitAuthSSHKey:"",gitAuthUser:"",gitAuthPass:"",gitAuthSSL:!0,gitSignatureName:"",gitSignatureEmail:"",adminEmail:"",adminPassword:"",adminPasswordConfirm:""},considerations:{https:!1,port:!1,localhost:!1}},computed:{currentProgress:function(){var t="0%";switch(this.state){case"welcome":t="0%";break;case"syscheck":t=this.syscheck.ok?"15%":"5%";break;case"general":t="20%";break;case"considerations":t="30%";break;case"db":t="35%";break;case"dbcheck":t=this.dbcheck.ok?"50%":"40%";break;case"paths":t="55%";break;case"git":t="60%";break;case"gitcheck":t=this.gitcheck.ok?"75%":"65%";break;case"admin":t="80%"}return t}},methods:{proceedToWelcome:function(t){this.state="welcome",this.loading=!1},proceedToSyscheck:function(t){var e=this;this.state="syscheck",this.loading=!0,e.syscheck={ok:!1,error:"",results:[]},_.delay(function(){axios.post("/syscheck").then(function(t){t.data.ok===!0?(e.syscheck.ok=!0,e.syscheck.results=t.data.results):(e.syscheck.ok=!1,e.syscheck.error=t.data.error),e.loading=!1,e.$nextTick()}).catch(function(t){window.alert(t.message)})},1e3)},proceedToGeneral:function(t){var e=this;e.state="general",e.loading=!1,e.$nextTick(function(){e.$validator.validateAll("general")})},proceedToConsiderations:function(t){this.considerations={https:!_.startsWith(this.conf.host,"https"),port:!1,localhost:_.includes(this.conf.host,"localhost")},this.state="considerations",this.loading=!1},proceedToDb:function(t){var e=this;e.state="db",e.loading=!1,e.$nextTick(function(){e.$validator.validateAll("db")})},proceedToDbcheck:function(t){var e=this;this.state="dbcheck",this.loading=!0,e.dbcheck={ok:!1,error:""},_.delay(function(){axios.post("/dbcheck",{db:e.conf.db}).then(function(t){t.data.ok===!0?e.dbcheck.ok=!0:(e.dbcheck.ok=!1,e.dbcheck.error=t.data.error),e.loading=!1,e.$nextTick()}).catch(function(t){window.alert(t.message)})},1e3)},proceedToPaths:function(t){var e=this;e.state="paths",e.loading=!1,e.$nextTick(function(){e.$validator.validateAll("paths")})},proceedToGit:function(t){var e=this;e.state="git",e.loading=!1,e.$nextTick(function(){e.$validator.validateAll("git")})},proceedToGitCheck:function(t){var e=this;this.state="gitcheck",this.loading=!0,e.gitcheck={ok:!1,results:[],error:""},_.delay(function(){axios.post("/gitcheck",e.conf).then(function(t){t.data.ok===!0?(e.gitcheck.ok=!0,e.gitcheck.results=t.data.results):(e.gitcheck.ok=!1,e.gitcheck.error=t.data.error),e.loading=!1,e.$nextTick()}).catch(function(t){window.alert(t.message)})},1e3)},proceedToAdmin:function(t){var e=this;e.state="admin",e.loading=!1,e.$nextTick(function(){e.$validator.validateAll("admin")})},proceedToFinal:function(t){var e=this;e.state="final",e.loading=!0,e.final={ok:!1,error:"",results:[]},_.delay(function(){axios.post("/finalize",e.conf).then(function(t){t.data.ok===!0?(e.final.ok=!0,e.final.results=t.data.results):(e.final.ok=!1,e.final.error=t.data.error),e.loading=!1,e.$nextTick()}).catch(function(t){window.alert(t.message)})},1e3)},finish:function(t){}}})}); | ||||
| @@ -46,6 +46,7 @@ jQuery(document).ready(function ($) { | ||||
|         db: 'mongodb://localhost:27017/wiki', | ||||
|         pathData: './data', | ||||
|         pathRepo: './repo', | ||||
|         gitUseRemote: true, | ||||
|         gitUrl: '', | ||||
|         gitBranch: 'master', | ||||
|         gitAuthType: 'ssh', | ||||
|   | ||||
							
								
								
									
										144
									
								
								configure.js
									
									
									
									
									
								
							
							
						
						
									
										144
									
								
								configure.js
									
									
									
									
									
								
							| @@ -51,14 +51,14 @@ module.exports = (port, spinner) => { | ||||
|  | ||||
|   app.get('*', (req, res) => { | ||||
|     let langs = [] | ||||
|     let conf = {} | ||||
|     try { | ||||
|       langs = yaml.safeLoad(fs.readFileSync('./app/data.yml', 'utf8')).langs | ||||
|       conf = yaml.safeLoad(fs.readFileSync('./config.yml', 'utf8')) | ||||
|     } catch (err) { | ||||
|       console.error(err) | ||||
|     } | ||||
|     res.render('configure/index', { | ||||
|       langs | ||||
|     }) | ||||
|     res.render('configure/index', { langs, conf }) | ||||
|   }) | ||||
|  | ||||
|   /** | ||||
| @@ -164,11 +164,16 @@ module.exports = (port, spinner) => { | ||||
|     const dataDir = path.resolve(ROOTPATH, req.body.pathData) | ||||
|     const gitDir = path.resolve(ROOTPATH, req.body.pathRepo) | ||||
|  | ||||
|     let urlObj = url.parse(req.body.gitUrl) | ||||
|     if (req.body.gitAuthType === 'basic') { | ||||
|       urlObj.auth = req.body.gitAuthUser + ':' + req.body.gitAuthPass | ||||
|     let gitRemoteUrl = '' | ||||
|     console.log(req.body) | ||||
|  | ||||
|     if (req.body.gitUseRemote === true) { | ||||
|       let urlObj = url.parse(req.body.gitUrl) | ||||
|       if (req.body.gitAuthType === 'basic') { | ||||
|         urlObj.auth = req.body.gitAuthUser + ':' + req.body.gitAuthPass | ||||
|       } | ||||
|       gitRemoteUrl = url.format(urlObj) | ||||
|     } | ||||
|     const gitRemoteUrl = url.format(urlObj) | ||||
|  | ||||
|     Promise.mapSeries([ | ||||
|       () => { | ||||
| @@ -183,21 +188,25 @@ module.exports = (port, spinner) => { | ||||
|         }) | ||||
|       }, | ||||
|       () => { | ||||
|         if (req.body.gitUseRemote === false) { return false } | ||||
|         return exec.stdout('git', ['config', '--local', 'user.name', req.body.gitSignatureName], { cwd: gitDir }).then(result => { | ||||
|           return 'Git Signature Name has been set successfully.' | ||||
|         }) | ||||
|       }, | ||||
|       () => { | ||||
|         if (req.body.gitUseRemote === false) { return false } | ||||
|         return exec.stdout('git', ['config', '--local', 'user.email', req.body.gitSignatureEmail], { cwd: gitDir }).then(result => { | ||||
|           return 'Git Signature Name has been set successfully.' | ||||
|         }) | ||||
|       }, | ||||
|       () => { | ||||
|         if (req.body.gitUseRemote === false) { return false } | ||||
|         return exec.stdout('git', ['config', '--local', '--bool', 'http.sslVerify', req.body.gitAuthSSL], { cwd: gitDir }).then(result => { | ||||
|           return 'Git SSL Verify flag has been set successfully.' | ||||
|         }) | ||||
|       }, | ||||
|       () => { | ||||
|         if (req.body.gitUseRemote === false) { return false } | ||||
|         if (req.body.gitAuthType === 'ssh') { | ||||
|           return exec.stdout('git', ['config', '--local', 'core.sshCommand', 'ssh -i "' + req.body.gitAuthSSHKey + '" -o StrictHostKeyChecking=no'], { cwd: gitDir }).then(result => { | ||||
|             return 'Git SSH Private Key path has been set successfully.' | ||||
| @@ -207,6 +216,7 @@ module.exports = (port, spinner) => { | ||||
|         } | ||||
|       }, | ||||
|       () => { | ||||
|         if (req.body.gitUseRemote === false) { return false } | ||||
|         return exec.stdout('git', ['remote', 'remove', 'origin'], { cwd: gitDir }).catch(err => { | ||||
|           if (_.includes(err.message, 'No such remote')) { | ||||
|             return true | ||||
| @@ -220,6 +230,7 @@ module.exports = (port, spinner) => { | ||||
|         }) | ||||
|       }, | ||||
|       () => { | ||||
|         if (req.body.gitUseRemote === false) { return false } | ||||
|         return exec.stdout('git', ['pull', 'origin', req.body.gitBranch], { cwd: gitDir }).then(result => { | ||||
|           return 'Git Pull operation successful.' | ||||
|         }) | ||||
| @@ -233,38 +244,115 @@ module.exports = (port, spinner) => { | ||||
|   }) | ||||
|  | ||||
|   /** | ||||
|    * Check the DB connection | ||||
|    * Finalize | ||||
|    */ | ||||
|   app.post('/finalize', (req, res) => { | ||||
|     const bcrypt = require('bcryptjs-then') | ||||
|     const crypto = Promise.promisifyAll(require('crypto')) | ||||
|     let mongo = require('mongodb').MongoClient | ||||
|     mongo.connect(req.body.db, { | ||||
|       autoReconnect: false, | ||||
|       reconnectTries: 2, | ||||
|       reconnectInterval: 1000, | ||||
|       connectTimeoutMS: 5000, | ||||
|       socketTimeoutMS: 5000 | ||||
|     }, (err, db) => { | ||||
|       if (err === null) { | ||||
|         // Try to create a test collection | ||||
|         db.createCollection('test', (err, results) => { | ||||
|  | ||||
|     Promise.join( | ||||
|       new Promise((resolve, reject) => { | ||||
|         mongo.connect(req.body.db, { | ||||
|           autoReconnect: false, | ||||
|           reconnectTries: 2, | ||||
|           reconnectInterval: 1000, | ||||
|           connectTimeoutMS: 5000, | ||||
|           socketTimeoutMS: 5000 | ||||
|         }, (err, db) => { | ||||
|           if (err === null) { | ||||
|             // Try to drop test collection | ||||
|             db.dropCollection('test', (err, results) => { | ||||
|             db.createCollection('users', { strict: false }, (err, results) => { | ||||
|               if (err === null) { | ||||
|                 res.json({ ok: true }) | ||||
|                 bcrypt.hash(req.body.adminPassword).then(adminPwdHash => { | ||||
|                   db.collection('users').findOneAndUpdate({ | ||||
|                     provider: 'local', | ||||
|                     email: req.body.adminEmail | ||||
|                   }, { | ||||
|                     provider: 'local', | ||||
|                     email: req.body.adminEmail, | ||||
|                     name: 'Administrator', | ||||
|                     password: adminPwdHash, | ||||
|                     rights: [{ | ||||
|                       role: 'admin', | ||||
|                       path: '/', | ||||
|                       exact: false, | ||||
|                       deny: false | ||||
|                     }], | ||||
|                     updatedAt: new Date(), | ||||
|                     createdAt: new Date() | ||||
|                   }, { | ||||
|                     upsert: true, | ||||
|                     returnOriginal: false | ||||
|                   }, (err, results) => { | ||||
|                     if (err === null) { | ||||
|                       resolve(true) | ||||
|                     } else { | ||||
|                       reject(err) | ||||
|                     } | ||||
|                     db.close() | ||||
|                   }) | ||||
|                 }) | ||||
|               } else { | ||||
|                 res.json({ ok: false, error: 'Unable to delete test collection. Verify permissions. ' + err.message }) | ||||
|                 reject(err) | ||||
|                 db.close() | ||||
|               } | ||||
|               db.close() | ||||
|             }) | ||||
|           } else { | ||||
|             res.json({ ok: false, error: 'Unable to create test collection. Verify permissions. ' + err.message }) | ||||
|             db.close() | ||||
|             reject(err) | ||||
|           } | ||||
|         }) | ||||
|       } else { | ||||
|         res.json({ ok: false, error: err.message }) | ||||
|       } | ||||
|       }), | ||||
|       fs.readFileAsync('./config.yml', 'utf8').then(confRaw => { | ||||
|         let conf = yaml.safeLoad(confRaw) | ||||
|         conf.title = req.body.title | ||||
|         conf.host = req.body.host | ||||
|         conf.port = req.body.port | ||||
|         conf.paths = { | ||||
|           repo: req.body.pathRepo, | ||||
|           data: req.body.pathData | ||||
|         } | ||||
|         conf.uploads = { | ||||
|           maxImageFileSize: (conf.uploads && _.isNumber(conf.uploads.maxImageFileSize)) ? conf.uploads.maxImageFileSize : 3, | ||||
|           maxOtherFileSize: (conf.uploads && _.isNumber(conf.uploads.maxOtherFileSize)) ? conf.uploads.maxOtherFileSize : 100 | ||||
|         } | ||||
|         conf.lang = req.body.lang | ||||
|         conf.public = (conf.public === true) | ||||
|         if (conf.auth && conf.auth.local) { | ||||
|           conf.auth.local = { enabled: true } | ||||
|         } else { | ||||
|           conf.auth = { local: { enabled: true } } | ||||
|         } | ||||
|         conf.admin = req.body.adminEmail | ||||
|         conf.db = req.body.db | ||||
|         if (req.body.gitUseRemote === false) { | ||||
|           conf.git = false | ||||
|         } else { | ||||
|           conf.git = { | ||||
|             url: req.body.gitUrl, | ||||
|             branch: req.body.gitBranch, | ||||
|             auth: { | ||||
|               type: req.body.gitAuthType, | ||||
|               username: req.body.gitAuthUser, | ||||
|               password: req.body.gitAuthPass, | ||||
|               privateKey: req.body.gitAuthSSHKey, | ||||
|               sslVerify: (req.body.gitAuthSSL === true) | ||||
|             }, | ||||
|             signature: { | ||||
|               name: req.body.gitSignatureName, | ||||
|               email: req.body.gitSignatureEmail | ||||
|             } | ||||
|           } | ||||
|         } | ||||
|         return crypto.randomBytesAsync(32).then(buf => { | ||||
|           conf.sessionSecret = buf.toString('hex') | ||||
|           confRaw = yaml.safeDump(conf) | ||||
|           return fs.writeFileAsync('./config.yml', confRaw) | ||||
|         }) | ||||
|       }) | ||||
|     ).then(() => { | ||||
|       res.json({ ok: true }) | ||||
|     }).catch(err => { | ||||
|       res.json({ ok: false, error: err.message }) | ||||
|     }) | ||||
|   }) | ||||
|  | ||||
|   | ||||
| @@ -1,6 +1,6 @@ | ||||
| { | ||||
|   "name": "wiki.js", | ||||
|   "version": "1.0.0-beta.8.1", | ||||
|   "version": "1.0.0-beta.9", | ||||
|   "description": "A modern, lightweight and powerful wiki app built on NodeJS, Git and Markdown", | ||||
|   "main": "install.js", | ||||
|   "scripts": { | ||||
|   | ||||
| @@ -1,6 +1,6 @@ | ||||
| { | ||||
|   "name": "wiki", | ||||
|   "version": "1.0.0-beta.8", | ||||
|   "version": "1.0.0-beta.9", | ||||
|   "description": "A modern, lightweight and powerful wiki app built on NodeJS, Git and Markdown", | ||||
|   "main": "server.js", | ||||
|   "scripts": { | ||||
|   | ||||
| @@ -278,8 +278,8 @@ html | ||||
|                 .panel-footer | ||||
|                   .progress-bar: div(v-bind:style='{width: currentProgress}') | ||||
|                   button.button.is-indigo.is-outlined(v-on:click='proceedToPaths', v-bind:disabled='loading') Back | ||||
|                   button.button.is-indigo.is-outlined(v-on:click='proceedToAdmin', v-bind:disabled='loading') Skip this step | ||||
|                   button.button.is-indigo(v-on:click='proceedToGitCheck', v-bind:disabled='loading || errors.any("git")') Continue | ||||
|                   button.button.is-indigo.is-outlined(v-on:click='conf.gitUseRemote = false; proceedToGitCheck()', v-bind:disabled='loading') Skip this step | ||||
|                   button.button.is-indigo(v-on:click='conf.gitUseRemote = true; proceedToGitCheck()', v-bind:disabled='loading || errors.any("git")') Continue | ||||
|              | ||||
|             //- ============================================== | ||||
|             //- GIT CHECK | ||||
| @@ -291,7 +291,7 @@ html | ||||
|                   span Git Repository Check | ||||
|                   i(v-if='loading') | ||||
|                 .panel-content.is-text | ||||
|                   p(v-if='loading') #[i.icon-loader.animated.rotateIn.infinite] Testing the connection to Git repository... | ||||
|                   p(v-if='loading') #[i.icon-loader.animated.rotateIn.infinite] Verifying Git repository settings... | ||||
|                   p(v-if='!loading && gitcheck.ok') | ||||
|                     ul | ||||
|                       li(v-for='rs in gitcheck.results') #[i.icon-check] {{rs}} | ||||
| @@ -321,18 +321,18 @@ html | ||||
|                     p.control.is-fullwidth | ||||
|                       label.label Administrator Email | ||||
|                       input(type='text', placeholder='e.g. admin@example.com', v-model='conf.adminEmail', data-vv-scope='admin', name='ipt-adminemail', v-validate='{ required: true, email: true }') | ||||
|                       span.desc The full git repository URL to connect to. | ||||
|                       span.desc The email address of the administrator account | ||||
|                   section.columns | ||||
|                     .column | ||||
|                       p.control.is-fullwidth | ||||
|                         label.label Password | ||||
|                         input(type='password', v-model='conf.adminPassword', data-vv-scope='admin', name='ipt-adminpwd', v-validate='{ required: true, min: 8 }') | ||||
|                         span.desc The full git repository URL to connect to. | ||||
|                         span.desc At least 8 characters long. | ||||
|                     .column | ||||
|                       p.control.is-fullwidth | ||||
|                         label.label Confirm Password | ||||
|                         input(type='password', v-model='conf.adminPasswordConfirm', data-vv-scope='admin', name='ipt-adminpwd2', v-validate='{ required: true, confirmed: "ipt-adminpwd" }') | ||||
|                         span.desc The git branch to use when synchronizing changes. | ||||
|                         span.desc Verify your password again. | ||||
|                 .panel-footer | ||||
|                   .progress-bar: div(v-bind:style='{width: currentProgress}') | ||||
|                   button.button.is-indigo.is-outlined(v-on:click='proceedToGit', v-bind:disabled='loading') Back | ||||
| @@ -349,9 +349,6 @@ html | ||||
|                   i(v-if='loading') | ||||
|                 .panel-content.is-text | ||||
|                   p(v-if='loading') #[i.icon-loader.animated.rotateIn.infinite] Finalizing your installation... | ||||
|                   p(v-if='!loading && final.ok') | ||||
|                     ul | ||||
|                       li(v-for='rs in final.results') #[i.icon-check] {{rs}} | ||||
|                   p(v-if='!loading && final.ok') | ||||
|                     i.icon-check | ||||
|                     strong  Wiki.js was configured successfully and is now ready for use. | ||||
| @@ -360,7 +357,7 @@ html | ||||
|                   p(v-if='!loading && !final.ok') #[i.icon-square-cross] Error: {{ final.error }} | ||||
|                 .panel-footer | ||||
|                   .progress-bar: div(v-bind:style='{width: currentProgress}') | ||||
|                   button.button.is-indigo.is-outlined(v-on:click='proceedToWelcome', v-bind:disabled='loading') Back | ||||
|                   button.button.is-indigo.is-outlined(v-on:click='proceedToAdmin', v-bind:disabled='loading') Back | ||||
|                   button.button.is-teal(v-on:click='proceedToFinal', v-if='!loading && !final.ok') Try Again | ||||
|                   button.button.is-green(v-on:click='finish', v-if='loading || final.ok', v-bind:disabled='loading') Start | ||||
|  | ||||
|   | ||||
		Reference in New Issue
	
	Block a user