Просмотр исходного кода

feat: added ability to convert accounts between versions

Kristian Vos 4 лет назад
Родитель
Сommit
bd9c022c36

+ 1 - 0
backend/index.js

@@ -164,6 +164,7 @@ moduleManager.addModule("util");
 moduleManager.addModule("mongo");
 moduleManager.addModule("account");
 moduleManager.addModule("accountSchema");
+moduleManager.addModule("convertSchema");
 
 moduleManager.initialize();
 

+ 100 - 1
backend/logic/account.js

@@ -19,6 +19,8 @@ module.exports = class extends coreClass {
 
 			this.mongoModule = this.moduleManager.modules["mongo"];
 			this.utilModule = this.moduleManager.modules["util"];
+			this.accountSchemaModule = this.moduleManager.modules["accountSchema"];
+			this.convertSchemaModule = this.moduleManager.modules["convertSchema"];
 
 			this.accountSchema = await this.mongoModule.schema("account");
 			this.accountModel = await this.mongoModule.model("account");
@@ -67,7 +69,9 @@ module.exports = class extends coreClass {
 		return new Promise(async (resolve, reject) => {
 			try { await this._validateHook(); } catch { return; }
 
-			this.accountModel.updateOne({ _id: accountId }, account, (err) => {
+			if (!accountId || !account) return reject(new Error("Account ID or Account invalid."));
+
+			this.accountModel.updateOne({ _id: accountId }, account, (err, res) => {
 				if (err) reject(new Error(err));
 				else {
 					this.utilModule.addAutosuggestAccount(account);
@@ -76,4 +80,99 @@ module.exports = class extends coreClass {
 			});
 		});
 	}
+
+	async getMigratedAccount(accountId) {
+		return new Promise(async (resolve, reject) => {
+			try { await this._validateHook(); } catch { return; }
+			if (!accountId) return reject(new Error("Account ID invalid."));
+
+			let oldAccount = await this.getById(accountId);
+
+			const latestVersion = (await this.accountSchemaModule.getLatest()).version;
+			if (oldAccount.version === latestVersion) return reject(new Error("Account is already up-to-date"));
+
+			let convertSchema;
+			try {
+				convertSchema = await this.convertSchemaModule.getForVersion(oldAccount.version);
+			} catch(err) {
+				reject(err);
+			}
+
+			let oldSchema = await this.accountSchemaModule.getByVersion(convertSchema.versionFrom);
+			let newSchema = await this.accountSchemaModule.getByVersion(convertSchema.versionTo);
+			
+			let defaultNewObjects = {};
+			let newAccount = {
+				fields: {},
+				version: convertSchema.versionTo
+			};
+
+			newSchema.fields.forEach(newField => {
+				const oldField = oldSchema.fields.find(oldField => oldField.fieldId === newField.fieldId);
+				let defaultNewObject = {};
+				newField.fieldTypes.forEach(newFieldType => {
+					if (newFieldType.type === "text" || newFieldType.type === "select") defaultNewObject[newFieldType.fieldTypeId] = "";
+					else if (newFieldType.type === "checkbox") defaultNewObject[newFieldType.fieldTypeId] = false;
+				});
+				defaultNewObjects[newField.fieldId] = defaultNewObject;
+
+				newAccount.fields[newField.fieldId] = [];
+
+				
+				if (oldField) {
+					let entries = [];
+
+					oldAccount.fields[oldField.fieldId].forEach(oldAccountFieldEntry => {
+						entries.push({});
+					});
+					 
+					newField.fieldTypes.forEach(newFieldType => {
+						const oldFieldType = oldField.fieldTypes.find(oldFieldType => oldFieldType.fieldTypeId === newFieldType.fieldTypeId);
+						if (oldFieldType) {
+							oldAccount.fields[oldField.fieldId].forEach((oldAccountFieldEntry, index) => {
+								entries[index][newFieldType.fieldTypeId] = oldAccountFieldEntry[newFieldType.fieldTypeId];
+							});
+						} else {
+							entries = entries.map(entry => {
+								entry[newFieldType.fieldTypeId] = defaultNewObject[newFieldType.fieldTypeId];
+								return entry;
+							});
+						}
+					});
+
+					newAccount.fields[newField.fieldId] = entries;
+				}
+			});
+
+			Object.keys(convertSchema.changes).forEach(changeOld => {
+				const oldFieldId = changeOld.split("+")[0];
+				const oldFieldTypeId = changeOld.split("+")[1];
+				const changeNew = convertSchema.changes[changeOld];
+				const newFieldId = changeNew.split("+")[0];
+				const newFieldTypeId = changeNew.split("+")[1];
+				
+				const oldField = oldAccount.fields[oldFieldId];
+				const newField = newAccount.fields[newFieldId];
+				
+				const entriesToAdd = oldField.length - newField.length;
+				for(let i = 0; i < entriesToAdd; i++) {
+					newAccount.fields[newFieldId].push(JSON.parse(JSON.stringify(defaultNewObjects[newFieldId])));
+				}
+
+				for(let i = 0; i < newField.length; i++) {
+					newAccount.fields[newFieldId][i][newFieldTypeId] = oldAccount.fields[oldFieldId][i][oldFieldTypeId];
+				}
+			});
+
+			newSchema.fields.forEach(newField => {
+				const entriesToAdd = newField.minEntries - newAccount.fields[newField.fieldId];
+
+				for(let i = 0; i < entriesToAdd; i++) {
+					newAccount.fields[newField.fieldId].push(JSON.parse(JSON.stringify(defaultNewObjects[newField.fieldId])));
+				}
+			});
+
+			resolve(newAccount);
+		});
+	}
 }

+ 22 - 0
backend/logic/accountSchema.js

@@ -37,6 +37,17 @@ module.exports = class extends coreClass {
 		});
 	}
 
+	async getAllVersions() {
+		return new Promise(async (resolve, reject) => {
+			try { await this._validateHook(); } catch { return; }
+
+			this.accountSchemaModel.find({}, null, { sort: "-version" }, (err, schemas) => {
+				if (err || !schemas) reject(new Error("Something went wrong."))
+				else resolve(schemas.map(schema => schema.version));
+			});
+		});
+	}
+
 	async getAll() {
 		return new Promise(async (resolve, reject) => {
 			try { await this._validateHook(); } catch { return; }
@@ -59,6 +70,17 @@ module.exports = class extends coreClass {
 		});
 	}
 
+	async getByVersion(version) {
+		return new Promise(async (resolve, reject) => {
+			try { await this._validateHook(); } catch { return; }
+
+			this.accountSchemaModel.findOne({ version }, (err, schema) => {
+				if (err || !schema) reject(new Error("Something went wrong."))
+				else resolve(schema)
+			});
+		});
+	}
+
 	async import(name) {
 		return new Promise(async (resolve, reject) => {
 			try { await this._validateHook(); } catch { return; }

+ 52 - 0
backend/logic/convertSchema.js

@@ -0,0 +1,52 @@
+'use strict';
+
+const async = require("async");
+
+const coreClass = require("../core");
+
+const config = require('config');
+
+module.exports = class extends coreClass {
+	constructor(name, moduleManager) {
+		super(name, moduleManager);
+
+		this.dependsOn = ["mongo"];
+	}
+
+	initialize() {
+		return new Promise(async (resolve, reject) => {
+			this.setStage(1);
+
+			this.mongoModule = this.moduleManager.modules["mongo"];
+
+			this.convertSchemaSchema = await this.mongoModule.schema("convertSchema");
+			this.convertSchemaModel = await this.mongoModule.model("convertSchema");
+
+			resolve();
+		})
+	}
+
+	async getForVersion(version) {
+		return new Promise(async (resolve, reject) => {
+			try { await this._validateHook(); } catch { return; }
+
+			this.convertSchemaModel.findOne({ versionFrom: version }, (err, schema) => {
+				if (err) reject(new Error(err.message));
+				else if (!schema) reject(new Error("Schema not found."));
+				else resolve(schema)
+			});
+		});
+	}
+
+	async import(name) {
+		return new Promise(async (resolve, reject) => {
+			try { await this._validateHook(); } catch { return; }
+
+			let schema = require(`../schemas/${name}`);
+			this.convertSchemaModel.create(schema, (err) => {
+				if (err) reject(new Error(err.message))
+				else resolve();
+			});
+		});
+	}
+}

+ 14 - 0
backend/logic/io/namespaces/account.js

@@ -31,6 +31,20 @@ module.exports = {
 		});
 	},
 
+	"getMigratedAccount": (cb, accountId) => {
+		accountModule.getMigratedAccount(accountId).then(account => {
+			cb({
+				status: "success",
+				account
+			});
+		}).catch(err => {
+			cb({
+				status: "failure",
+				message: err.message
+			});
+		});
+	},
+
 	"add": (cb, account) => {
 		accountModule.add(account).then(() => {
 			console.log("Added account!");

+ 26 - 0
backend/logic/io/namespaces/accountSchema.js

@@ -17,6 +17,32 @@ module.exports = {
 		});
 	},
 
+	"getByVersion": (cb, version) => {
+		accountSchemaModule.getByVersion(version).then(schema => {
+			cb({
+				status: "success",
+				schema
+			});
+		}).catch(err => {
+			cb({
+				status: "failure"
+			});
+		});
+	},
+
+	"getAllVersions": (cb) => {
+		accountSchemaModule.getAllVersions().then(versions => {
+			cb({
+				status: "success",
+				versions
+			});
+		}).catch(err => {
+			cb({
+				status: "failure"
+			});
+		});
+	},
+
 	"getAll": cb => {
 		accountSchemaModule.getAll().then(schemas => {
 			cb({

+ 32 - 0
backend/logic/io/namespaces/convertSchema.js

@@ -0,0 +1,32 @@
+const moduleManager = require("../../../index");
+
+const mongoModule = moduleManager.modules["mongo"];
+const convertSchemaModule = moduleManager.modules["convertSchema"];
+
+module.exports = {
+	"getForVersion": (cb, version) => {
+		convertSchemaModule.getForVersion(version).then(schema => {
+			cb({
+				status: "success",
+				schema
+			});
+		}).catch(err => {
+			cb({
+				status: "failure"
+			});
+		});
+	},
+
+	"import": (cb, name) => {
+		convertSchemaModule.import(name).then(() => {
+			cb({
+				status: "success"
+			});
+		}).catch(err => {
+			cb({
+				status: "failure",
+				error: err.message
+			});
+		});
+	}
+}

+ 1 - 0
backend/logic/io/namespaces/index.js

@@ -1,5 +1,6 @@
 module.exports = {
 	account: require("./account.js"),
 	accountSchema: require("./accountSchema.js"),
+	convertSchema: require("./convertSchema.js"),
 	util: require("./util.js")
 }

+ 2 - 0
backend/logic/mongo/index.js

@@ -27,11 +27,13 @@ module.exports = class extends coreClass {
 			})
 				.then(() => {
 					this._schemas = {
+						convertSchema: new mongoose.Schema(require(`./schemas/convertSchema`)),
 						accountSchema: new mongoose.Schema(require(`./schemas/accountSchema`)),
 						account: new mongoose.Schema(require(`./schemas/account`))
 					};
 		
 					this._models = {
+						convertSchema: mongoose.model('convertSchema', this._schemas.convertSchema),
 						accountSchema: mongoose.model('accountSchema', this._schemas.accountSchema),
 						account: mongoose.model('account', this._schemas.account)
 					};

+ 5 - 0
backend/logic/mongo/schemas/convertSchema.js

@@ -0,0 +1,5 @@
+module.exports = {
+	versionFrom: { type: Number, required: true },
+	versionTo: { type: Number, required: true },
+	changes: { type: Object }
+};

+ 4 - 2
backend/logic/util.js

@@ -25,7 +25,7 @@ module.exports = class extends coreClass {
 			this.accountSchemaModel = await this.mongoModule.model("accountSchema");
 			this.accountModel = await this.mongoModule.model("account");
 
-			async.waterfall([
+			/*async.waterfall([
 				(next) => {
 					this.accountSchemaModel.find({}, null, { sort: "-version", limit: 1 }, next);
 				},
@@ -61,7 +61,9 @@ module.exports = class extends coreClass {
 			], (err) => {
 				if (err) reject(new Error(err));
 				else resolve();
-			});
+			});*/
+
+			resolve();
 		})
 	}
 

+ 416 - 0
backend/schemas/accountSchemaV6.js

@@ -0,0 +1,416 @@
+module.exports = {
+	name: "Account",
+	description: "Account schema (experimental, use V5 for normal usecases)",
+	version: 6,
+	fields: [
+		{
+			name: "Name",
+			fieldId: "name",
+			fieldTypes: [
+				{
+					type: "text",
+					fill: true,
+					fieldTypeId: "name"
+				}
+			],
+			minEntries: 1,
+			maxEntries: 1
+		},
+		{
+			name: "Domain",
+			fieldId: "domain",
+			fieldTypes: [
+				{
+					type: "text",
+					fill: true,
+					fieldTypeId: "domain",
+					autosuggestGroup: "domain"
+				}
+			],
+			minEntries: 0,
+			maxEntries: 10
+		},
+		{
+			name: "App",
+			fieldId: "newapp",
+			fieldTypes: [
+				{
+					type: "select",
+					options: [
+						{
+							value: "android",
+							text: "Android"
+						},
+						{
+							value: "ios",
+							text: "iOS"
+						},
+						{
+							value: "windows",
+							text: "Windows"
+						}
+					],
+					fieldTypeId: "newappType"
+				},
+				{
+					type: "text",
+					fill: true,
+					fieldTypeId: "newappName"
+				}
+			],
+			minEntries: 0,
+			maxEntries: 10
+		},
+		{
+			name: "Account exists",
+			fieldId: "accountExists",
+			fieldTypes: [
+				{
+					type: "checkbox",
+					fieldTypeId: "accountExists"
+				}
+			],
+			minEntries: 0,
+			maxEntries: 1
+		},
+		{
+			name: "E-mail",
+			fieldId: "newemail",
+			fieldTypes: [
+				{
+					type: "text",
+					fieldTypeId: "newemail",
+					fill: true,
+					autosuggestGroup: "email"
+				}
+			],
+			minEntries: 0,
+			maxEntries: 10
+		},
+		{
+			name: "Username",
+			fieldId: "username",
+			fieldTypes: [
+				{
+					type: "text",
+					fieldTypeId: "username",
+					fill: true
+				}
+			],
+			minEntries: 0,
+			maxEntries: 10
+		},
+		{
+			name: "Login name",
+			fieldId: "loginName",
+			fieldTypes: [
+				{
+					type: "text",
+					fieldTypeId: "loginName",
+					fill: true
+				}
+			],
+			minEntries: 0,
+			maxEntries: 10
+		},
+		{
+			name: "Category",
+			fieldId: "category",
+			fieldTypes: [
+				{
+					type: "text",
+					fieldTypeId: "category",
+					fill: true
+				}
+			],
+			minEntries: 0,
+			maxEntries: 10
+		},
+		{
+			name: "Uses password",
+			fieldId: "newusesPassword",
+			fieldTypes: [
+				{
+					type: "checkbox",
+					fieldTypeId: "newusesPassword"
+				}
+			],
+			minEntries: 0,
+			maxEntries: 1
+		},
+		{
+			name: "Password last changed",
+			fieldId: "passwordLastChanged",
+			fieldTypes: [
+				{
+					type: "text",
+					fieldTypeId: "passwordLastChanged",
+					fill: true
+				}
+			],
+			minEntries: 0,
+			maxEntries: 1
+		},
+		{
+			name: "2FA possible",
+			fieldId: "newtwofaPossible",
+			fieldTypes: [
+				{
+					type: "select",
+					options: [
+						{
+							value: "otp",
+							text: "OTP"
+						},
+						{
+							value: "sms",
+							text: "SMS"
+						}
+					],
+					fieldTypeId: "newtwofaPossibleType",
+					fill: true
+				}
+			],
+			minEntries: 0,
+			maxEntries: 10
+		},
+		{
+			name: "2FA used",
+			fieldId: "twofaUsed",
+			fieldTypes: [
+				{
+					type: "select",
+					options: [
+						{
+							value: "otp",
+							text: "OTP"
+						},
+						{
+							value: "sms",
+							text: "SMS"
+						}
+					],
+					fieldTypeId: "twofaUsedType"
+				},
+				{
+					type: "text",
+					fieldTypeId: "twofaUsedValue",
+					fill: true
+				}
+			],
+			minEntries: 0,
+			maxEntries: 10
+		},
+		{
+			name: "2FA recovery method",
+			fieldId: "twofaRecovery",
+			fieldTypes: [
+				{
+					type: "select",
+					options: [
+						{
+							value: "backupCodes",
+							text: "Backup codes"
+						}
+					],
+					fieldTypeId: "twofaRecoveryMethod"
+				},
+				{
+					type: "text",
+					fieldTypeId: "twofaRecoveryValue",
+					fill: true
+				}
+			],
+			minEntries: 0,
+			maxEntries: 10
+		},
+		{
+			name: "Login service",
+			fieldId: "loginService",
+			fieldTypes: [
+				{
+					type: "text",
+					fieldTypeId: "loginService",
+					fill: true
+				}
+			],
+			minEntries: 0,
+			maxEntries: 10
+		},
+		{
+			name: "Service linked",
+			fieldId: "serviceLinked",
+			fieldTypes: [
+				{
+					type: "text",
+					fieldTypeId: "serviceLinked",
+					fill: true
+				}
+			],
+			minEntries: 0,
+			maxEntries: 10
+		},
+		{
+			name: "Uses security questions",
+			fieldId: "usesSecurityQuestions",
+			fieldTypes: [
+				{
+					type: "checkbox",
+					fieldTypeId: "usesSecurityQuestions",
+					fill: true
+				}
+			],
+			minEntries: 0,
+			maxEntries: 1
+		},
+		{
+			name: "Recovery e-mail",
+			fieldId: "recoveryEmail",
+			fieldTypes: [
+				{
+					type: "text",
+					fieldTypeId: "recoveryEmail",
+					fill: true,
+					autosuggestGroup: "email"
+				}
+			],
+			minEntries: 0,
+			maxEntries: 10
+		},
+		{
+			name: "Recovery phone number",
+			fieldId: "recoveryPhoneNumber",
+			fieldTypes: [
+				{
+					type: "text",
+					fieldTypeId: "recoveryPhoneNumber",
+					fill: true
+				}
+			],
+			minEntries: 0,
+			maxEntries: 10
+		},
+		{
+			name: "Comments",
+			fieldId: "comments",
+			fieldTypes: [
+				{
+					type: "text",
+					fieldTypeId: "comments",
+					fill: true
+				}
+			],
+			minEntries: 0,
+			maxEntries: 1
+		},
+		{
+			name: "In 1password",
+			fieldId: "in1password",
+			fieldTypes: [
+				{
+					type: "checkbox",
+					fieldTypeId: "in1password"
+				}
+			],
+			minEntries: 0,
+			maxEntries: 1
+		},
+		{
+			name: "Deleted",
+			fieldId: "deleted",
+			fieldTypes: [
+				{
+					type: "checkbox",
+					fieldTypeId: "deleted"
+				}
+			],
+			minEntries: 0,
+			maxEntries: 1
+		},
+		{
+			name: "Deleted at",
+			fieldId: "newdeletedAt",
+			fieldTypes: [
+				{
+					type: "text",
+					fieldTypeId: "newdeletedAt",
+					fill: true
+				}
+			],
+			minEntries: 2,
+			maxEntries: 3
+		},
+		{
+			name: "Service accessible",
+			fieldId: "serviceAccessible",
+			fieldTypes: [
+				{
+					type: "checkbox",
+					fieldTypeId: "serviceAccessible"
+				}
+			],
+			minEntries: 0,
+			maxEntries: 1
+		},
+		{
+			name: "Requested deletion",
+			fieldId: "requestedDeletion",
+			fieldTypes: [
+				{
+					type: "checkbox",
+					fieldTypeId: "requestedDeletion"
+				}
+			],
+			minEntries: 0,
+			maxEntries: 1
+		},
+		{
+			name: "Requested deletion at",
+			fieldId: "requestedDeletionAt",
+			fieldTypes: [
+				{
+					type: "text",
+					fieldTypeId: "requestedDeletionAt",
+					fill: true
+				}
+			],
+			minEntries: 0,
+			maxEntries: 1
+		},
+		{
+			name: "To delete",
+			fieldId: "toDelete",
+			fieldTypes: [
+				{
+					type: "checkbox",
+					fieldTypeId: "toDelete"
+				}
+			],
+			minEntries: 0,
+			maxEntries: 1
+		},
+		{
+			name: "Created at",
+			fieldId: "createdAt",
+			fieldTypes: [
+				{
+					type: "text",
+					fieldTypeId: "newcreatedAt",
+					fill: true
+				}
+			],
+			minEntries: 0,
+			maxEntries: 1
+		}
+	],
+	dependencies: {
+		"twofaUsed": {
+			eval: "{fields}.newtwofaPossible.length > 0",
+			fieldId: "newtwofaPossible"
+		},
+		"twofaRecovery": {
+			eval: "{fields}.twofaUsed.length > 0",
+			fieldId: "twofaUsed"
+		}
+	}
+};

+ 13 - 0
backend/schemas/convertV4toV6.js

@@ -0,0 +1,13 @@
+module.exports = {
+	versionFrom: 4,
+	versionTo: 6,
+	changes: {
+		"app+appType": "newapp+newappType",
+		"app+appName": "newapp+newappName",
+		"email+email": "newemail+newemail",
+		"usesPassword+usesPassword": "newusesPassword+newusesPassword",
+		"twofaPossible+twofaPossibleType": "newtwofaPossible+newtwofaPossibleType",
+		"deletedAt+deletedAt": "newdeletedAt+newdeletedAt",
+		"createdAt+createdAt": "createdAt+newcreatedAt"
+	}
+}

+ 13 - 0
backend/schemas/convertV5toV6.js

@@ -0,0 +1,13 @@
+module.exports = {
+	versionFrom: 5,
+	versionTo: 6,
+	changes: {
+		"app+appType": "newapp+newappType",
+		"app+appName": "newapp+newappName",
+		"email+email": "newemail+newemail",
+		"usesPassword+usesPassword": "newusesPassword+newusesPassword",
+		"twofaPossible+twofaPossibleType": "newtwofaPossible+newtwofaPossibleType",
+		"deletedAt+deletedAt": "newdeletedAt+newdeletedAt",
+		"createdAt+createdAt": "createdAt+newcreatedAt"
+	}
+}

+ 8 - 0
frontend/main.js

@@ -27,6 +27,14 @@ const router = new VueRouter({
 			path: "/schemas",
 			component: () => import("./vue/pages/Schemas.vue")
 		},
+		{
+			path: "/convert",
+			component: () => import("./vue/pages/ConvertAccounts.vue")
+		},
+		{
+			path: "/convert/:accountId",
+			component: () => import("./vue/pages/ConvertAccount.vue")
+		},
 		{
 			path: "/options",
 			component: () => import("./vue/pages/Options.vue")

+ 51 - 30
frontend/vue/components/AccountForm.vue

@@ -12,8 +12,9 @@
 			:key="field.fieldId"
 			:ref="field.fieldId"
 			:onChange="onFieldChange(field.fieldId)"
+			:readonly="readonly"
 			:fieldTypes="field.fieldTypes"/>
-			<button @click="submit()" type="button" class="button">
+			<button @click="submit()" type="button" class="button" v-if="!readonly">
 				Submit
 			</button>
 	</form>
@@ -30,6 +31,7 @@ export default {
 		return {
 			fields: [],
 			account: {},
+			schema: {},
 			autosuggest: {},
 			dependencies: {}
 		};
@@ -50,49 +52,68 @@ export default {
 			let dependencyFieldId = dependency.fieldId;
 			if (!this.dependencyChecksOut(dependencyFieldId)) return false;
 			let dependencyEval = dependency.eval.replace("{fields}", "this.account.fields");
-			return eval(dependencyEval);
+			/*try {
+				return eval(dependencyEval);
+			} catch(err) {
+				console.log("Eval error", err);
+				return false;
+			}*/
+			return false;
 		},
 		onFieldChange(fieldId) {
 			return () => {
 				this.$set(this.account.fields, fieldId, this.$refs[fieldId][0].entries);
 			};
+		},
+		initializeAccount() {
+			if (!this.initialAccount) {
+				this.$set(this.account, "fields", {});
+				this.$set(this.account, "version", this.schema.version);
+
+				this.fields.forEach(field => {
+					let defaultObject = {};
+					field.fieldTypes.forEach(fieldType => {
+						if (fieldType.type === "text" || fieldType.type === "select") defaultObject[fieldType.fieldTypeId] = "";
+						else if (fieldType.type === "checkbox") defaultObject[fieldType.fieldTypeId] = false;
+					});
+					
+					this.$set(this.account.fields, field.fieldId, []);
+
+					for(let i = 0; i < field.minEntries; i++) {
+						this.account.fields[field.fieldId].push(defaultObject);
+					}
+				});
+
+				this.templateAccount = this.account;
+			} else {
+				this.account = this.initialAccount;
+				this.templateAccount = this.initialAccount;
+			}
 		}
 	},
 	props: {
 		onSubmit: Function,
-		initialAccount: Object
+		initialAccount: Object,
+		readonly: Boolean
 	},
 	mounted() {
 		io.getSocket(socket => {
 			this.socket = socket;
 
-			socket.emit("accountSchema.getLatest", res => {
-				this.fields = res.schema.fields;
-				this.dependencies = res.schema.dependencies;
-				if (!this.initialAccount) {
-					this.$set(this.account, "fields", {});
-					this.$set(this.account, "version", res.schema.version);
-
-					this.fields.forEach(field => {
-						let defaultObject = {};
-						field.fieldTypes.forEach(fieldType => {
-							if (fieldType.type === "text" || fieldType.type === "select") defaultObject[fieldType.fieldTypeId] = "";
-							else if (fieldType.type === "checkbox") defaultObject[fieldType.fieldTypeId] = false;
-						});
-						
-						this.$set(this.account.fields, field.fieldId, []);
-
-						for(let i = 0; i < field.minEntries; i++) {
-							this.account.fields[field.fieldId].push(defaultObject);
-						}
-					});
-
-					this.templateAccount = this.account;
-				} else {
-					this.account = this.initialAccount;
-					this.templateAccount = this.initialAccount;
-				}
-			});
+			if (this.initialAccount) {
+				socket.emit("accountSchema.getByVersion", this.initialAccount.version, res => {
+					this.fields = res.schema.fields;
+					this.dependencies = (res.schema.dependencies) ? res.schema.dependencies : {};
+					this.initializeAccount();
+				});
+			} else {
+				socket.emit("accountSchema.getLatest", res => {
+					this.fields = res.schema.fields;
+					this.schema = res.schema;
+					this.dependencies = (res.schema.dependencies) ? res.schema.dependencies : {};
+					this.initializeAccount();
+				});
+			}
 
 			socket.emit("util.getAutosuggest", res => {
 				this.autosuggest = res.autosuggest;

+ 14 - 8
frontend/vue/components/Field.vue

@@ -1,18 +1,18 @@
 <template>
 	<div class="control">
 		<label for="name">{{ name }}</label>
-		<button v-if="entries.length === 0" @click="addEntry()" type="button">+</button>
+		<button v-if="entries.length === 0 && !readonly" @click="addEntry()" type="button">+</button>
 		<div class="control-row" v-for="(entry, entryIndex) in entries">
 			<div class="control-col" v-for="(fieldType, fieldIndex) in fieldTypes" :class="{ 'fill-remaining': fieldType.fill }">
-				<input :ref="`input:${entryIndex}:${fieldType.fieldTypeId}`" name="name" type="text" v-if="fieldType.type === 'text'" v-model="entry[fieldType.fieldTypeId]" @change="onInputChange()" v-on:blur="blurInput(entryIndex, fieldType.fieldTypeId)" v-on:focus="focusInput(entryIndex, fieldType.fieldTypeId)" v-on:keydown="keydownInput(entryIndex, fieldType.fieldTypeId)" @keyup.ctrl.exact.59="fillInput(entryIndex, fieldType.fieldTypeId, 'date')" @keyup.ctrl.shift.exact.59="fillInput(entryIndex, fieldType.fieldTypeId, 'time')" />
-				<select name="name" v-if="fieldType.type === 'select'" class="fill-remaining" v-model="entry[fieldType.fieldTypeId]" @change="onSelectChange()">
+				<input :ref="`input:${entryIndex}:${fieldType.fieldTypeId}`" :disabled="readonly" name="name" type="text" v-if="fieldType.type === 'text'" v-model="entry[fieldType.fieldTypeId]" @change="onInputChange()" v-on:blur="blurInput(entryIndex, fieldType.fieldTypeId)" v-on:focus="focusInput(entryIndex, fieldType.fieldTypeId)" v-on:keydown="keydownInput(entryIndex, fieldType.fieldTypeId)" @keyup.ctrl.exact.59="fillInput(entryIndex, fieldType.fieldTypeId, 'date')" @keyup.ctrl.shift.exact.59="fillInput(entryIndex, fieldType.fieldTypeId, 'time')" />
+				<select name="name" v-if="fieldType.type === 'select'" :disabled="readonly" class="fill-remaining" v-model="entry[fieldType.fieldTypeId]" @change="onSelectChange()">
 					<option v-for="option in fieldType.options" :value="option.value">{{option.text}}</option>
 				</select>
-				<div tabindex="0" v-on:keyup.enter="toggleCheckbox(entryIndex, fieldType.fieldTypeId)" v-on:keyup.space="toggleCheckbox(entryIndex, fieldType.fieldTypeId)" name="name" class="checkbox" v-if="fieldType.type === 'checkbox'" :class="{ checked: entry[fieldType.fieldTypeId] }" @click="toggleCheckbox(entryIndex, fieldType.fieldTypeId)"></div>
+				<div tabindex="0" v-on:keyup.enter="toggleCheckbox(entryIndex, fieldType.fieldTypeId)" v-on:keyup.space="toggleCheckbox(entryIndex, fieldType.fieldTypeId)" name="name" class="checkbox" v-if="fieldType.type === 'checkbox'" :class="{ checked: entry[fieldType.fieldTypeId], disabled: readonly }" @click="toggleCheckbox(entryIndex, fieldType.fieldTypeId)"></div>
 
-				<button v-if="fieldType.extraButtons" v-for="buttonInfo in fieldType.extraButtons" type="button" :class="[buttonInfo.style]">{{buttonInfo.icon}}</button>
-				<button v-if="entryIndex + 1 === entries.length && entryIndex + 1 < maxEntries && fieldIndex + 1 === fieldTypes.length" @click="addEntry()" type="button">+</button>
-				<button v-if="entries.length > minEntries && fieldIndex + 1 === fieldTypes.length" @click="removeEntry(entryIndex)" type="button">-</button>
+				<button v-if="fieldType.extraButtons && !readonly" v-for="buttonInfo in fieldType.extraButtons" type="button" :class="[buttonInfo.style]">{{buttonInfo.icon}}</button>
+				<button v-if="entryIndex + 1 === entries.length && entryIndex + 1 < maxEntries && fieldIndex + 1 === fieldTypes.length && !readonly" @click="addEntry()" type="button">+</button>
+				<button v-if="entries.length > minEntries && fieldIndex + 1 === fieldTypes.length && !readonly" @click="removeEntry(entryIndex)" type="button">-</button>
 
 				<div v-if="fieldType.autosuggestGroup && (focusedInput === `${entryIndex}.${fieldType.fieldTypeId}` || autosuggestHover === `${entryIndex}.${fieldType.fieldTypeId}`)" class="autosuggest-container" @mouseover="focusAutosuggestContainer(entryIndex, fieldType.fieldTypeId)" @mouseleave="blurAutosuggestContainer()">
 					<div v-for="autosuggestItem in filter(autosuggest[fieldType.autosuggestGroup], entry[fieldType.fieldTypeId])" class="autosuggest-item" @click="selectAutosuggest(entryIndex, fieldType.fieldTypeId, autosuggestItem)">
@@ -46,7 +46,8 @@ export default {
 		maxEntries: Number,
 		initialEntries: Array,
 		autosuggest: Object,
-		onChange: Function
+		onChange: Function,
+		readonly: Boolean
 	},
 	mounted() {
 		
@@ -66,6 +67,7 @@ export default {
 			this.onChange();
 		},
 		toggleCheckbox(entryIndex, fieldTypeId) {
+			if (this.readonly) return;
 			this.entries[entryIndex][fieldTypeId] = !this.entries[entryIndex][fieldTypeId];
 			this.onChange();
 		},
@@ -229,6 +231,10 @@ export default {
 	cursor: pointer;
 	position: relative;
 	box-sizing: border-box;
+
+	&.disabled {
+		cursor:auto;
+	}
 }
 
 .checkbox.checked::after {

+ 5 - 0
frontend/vue/components/Navbar.vue

@@ -15,6 +15,11 @@
 				Schemas
 			</a>
 		</router-link>
+		<router-link to="/convert" v-slot="{ href, navigate, isExactActive }">
+			<a :class="{ 'active': isExactActive }" :href="href" @click="navigate">
+				Convert
+			</a>
+		</router-link>
 		<router-link to="/options" v-slot="{ href, navigate, isExactActive }">
 			<a :class="{ 'active': isExactActive }" :href="href" @click="navigate">
 				Options

+ 6 - 1
frontend/vue/pages/Accounts.vue

@@ -72,10 +72,15 @@ export default {
 		localData: function() {
 			return this.accounts.map(account => {
 				const completePercentage = (Object.keys(account.fields).filter(fieldName => account.fields[fieldName].length >= 1).length / Object.keys(account.fields).length) * 100;
+				let email = "";
+
+				if (account.version === 6) email = account.fields.newemail.map(newemail => newemail.newemail).join(", ");
+				else account.fields.email.map(email => email.email).join(", ");
+
 				return {
 					name: account.fields.name[0].name,
 					domain: account.fields.domain.map(domain => domain.domain).join(", "),
-					email: account.fields.email.map(email => email.email).join(", "),
+					email,
 					complete: `${(completePercentage % 1 > 0) ? completePercentage.toFixed(2) : completePercentage}%`,
 					accountId: account._id
 				};

+ 164 - 0
frontend/vue/pages/ConvertAccount.vue

@@ -0,0 +1,164 @@
+<template>
+	<main>
+		<h1>Convert account</h1>
+		<hr/>
+		<br/>
+		<account-form class="account-form" v-if="oldAccount.version" :onSubmit="() => {}" :initialAccount="oldAccount" :readonly="true"/>
+		<account-form class="account-form" v-if="newAccount.version" :onSubmit="onSubmit" :initialAccount="newAccount"/>
+		<p v-else>{{ message }}</p>
+	</main>
+</template>
+
+<script>
+import AccountForm from '../components/AccountForm.vue';
+
+import io from "../../io.js";
+
+export default {
+	components: { AccountForm },
+	data: () => {
+		return {
+			oldAccount: {},
+			newAccount: {},
+			accountId: "",
+			message: ""
+		}
+	},
+	methods: {
+		onSubmit(account) {
+			console.log(account);
+			this.socket.emit("account.editById", this.oldAccount._id, account, (res) => {
+				console.log(res);
+				if (res.status === "success") {
+					this.$router.push("/accounts")
+				}
+			});
+		},
+		createMigratingAccount() {
+			/*this.$set(this.newAccount, "fields", {});
+			this.$set(this.newAccount, "version", this.newSchema.version);
+
+			let defaultNewObjects = {};
+
+			this.newSchema.fields.forEach(newField => {
+				const oldField = this.oldSchema.fields.find(oldField => oldField.fieldId === newField.fieldId);
+				let defaultNewObject = {};
+				//console.log(newField.fieldId, newField);
+				newField.fieldTypes.forEach(newFieldType => {
+					if (newFieldType.type === "text" || newFieldType.type === "select") defaultNewObject[newFieldType.fieldTypeId] = "";
+					else if (newFieldType.type === "checkbox") defaultNewObject[newFieldType.fieldTypeId] = false;
+				});
+				defaultNewObjects[newField.fieldId] = defaultNewObject;
+
+				this.$set(this.newAccount.fields, newField.fieldId, []);
+
+				
+				if (oldField) { // If the new field id is the same in the old & new schema
+					// console.log("FIELD STILL EXISTS", newField.fieldId);
+
+					let entries = [];
+
+					this.oldAccount.fields[oldField.fieldId].forEach(oldAccountFieldEntry => {
+						entries.push({});
+					});
+					 
+					newField.fieldTypes.forEach(newFieldType => {
+						const oldFieldType = oldField.fieldTypes.find(oldFieldType => oldFieldType.fieldTypeId === newFieldType.fieldTypeId);
+						if (oldFieldType) { // If the new field type id is the same in the old & new schema
+							// console.log("FIELDTYPE STILL EXISTS", newFieldType.fieldTypeId);
+							this.oldAccount.fields[oldField.fieldId].forEach((oldAccountFieldEntry, index) => {
+								entries[index][newFieldType.fieldTypeId] = oldAccountFieldEntry[newFieldType.fieldTypeId];
+							});
+						} else { // If the new field type id was not in the old schema
+							// console.log("NEW FIELDTYPE", newFieldType.fieldTypeId);
+							entries = entries.map(entry => {
+								entry[newFieldType.fieldTypeId] = defaultNewObject[newFieldType.fieldTypeId];
+								return entry;
+							});
+						}
+					});
+
+					this.$set(this.newAccount.fields, newField.fieldId, entries);
+				}
+			});
+
+			Object.keys(this.migrate.changes).forEach(changeOld => {
+				const oldFieldId = changeOld.split(".")[0];
+				const oldFieldTypeId = changeOld.split(".")[1];
+				const changeNew = this.migrate.changes[changeOld];
+				const newFieldId = changeNew.split(".")[0];
+				const newFieldTypeId = changeNew.split(".")[1];
+				
+				const oldField = this.oldAccount.fields[oldFieldId];
+				const newField = this.newAccount.fields[newFieldId];
+				
+				//console.log(oldField, newField);
+
+				const entriesToAdd = oldField.length - newField.length;
+				for(let i = 0; i < entriesToAdd; i++) {
+					this.newAccount.fields[newFieldId].push(JSON.parse(JSON.stringify(defaultNewObjects[newFieldId])));
+				}
+
+				for(let i = 0; i < newField.length; i++) {
+					//console.log(i, this.oldAccount.fields[oldFieldId][i][oldFieldTypeId], this.newAccount.fields[newFieldId][i][newFieldTypeId]);
+					//this.$set(this.newAccount.fields[newFieldId][i], `${newFieldTypeId}`, this.oldAccount.fields[oldFieldId][i][oldFieldTypeId]);
+					this.$set(this.newAccount.fields[newFieldId][i], newFieldTypeId, this.oldAccount.fields[oldFieldId][i][oldFieldTypeId]);
+					//console.log(this.newAccount.fields[newFieldId][i]);
+				}
+			});
+
+			this.newSchema.fields.forEach(newField => {
+				const entriesToAdd = newField.minEntries - this.newAccount.fields[newField.fieldId];
+
+				for(let i = 0; i < entriesToAdd; i++) {
+					this.newAccount.fields[newField.fieldId].push(JSON.parse(JSON.stringify(defaultNewObjects[newField.fieldId])));
+				}
+			});
+
+			//console.log(defaultNewObjects["newemail"]);*/
+		}
+	},
+	mounted() {
+		this.accountId = this.$route.params.accountId;
+		io.getSocket(socket => {
+			this.socket = socket;
+
+			this.socket.emit("account.getById", this.accountId, res => {
+				console.log(res);
+				if (res.status === "success") {
+					this.oldAccount = res.account;
+				}
+			});
+
+			this.socket.emit("account.getMigratedAccount", this.accountId, res => {
+				console.log(res);
+				if (res.status === "success") {
+					this.newAccount = res.account;
+				} else this.message = res.message;
+			});
+
+			/*this.socket.emit("accountSchema.getByVersion", this.migrate.versionFrom, res => {
+				this.oldSchema = res.schema;
+
+				this.socket.emit("accountSchema.getByVersion", this.migrate.versionTo, res2 => {
+					this.newSchema = res2.schema;
+
+
+					this.createMigratingAccount();
+				});
+			});*/
+
+			
+		});	}
+};
+</script>
+
+<style lang="scss" scoped>
+.account-form {
+	float: left;
+
+	&:last-of-type {
+		margin-left: 32px;
+	}
+}
+</style>

+ 98 - 0
frontend/vue/pages/ConvertAccounts.vue

@@ -0,0 +1,98 @@
+<template>
+	<main>
+		<h1>Accounts</h1>
+		<hr/>
+		<br/>
+		<input v-model="importConvertSchemaName"/>
+		<button @click="importConvertSchema()" class="button">Import convert schema</button>
+		<br/>
+		<br/>
+		<data-table ref="datatable"
+			:fields="fields"
+			:sort-order="sortOrder"
+			:data="localData"
+		>
+			<div slot="actions-slot" slot-scope="props">
+				<router-link
+					:to="`/convert/${props.data.accountId}`"
+					class="button"
+				>
+					Convert account
+				</router-link>
+			</div>
+		</data-table>
+	</main>
+</template>
+
+<script>
+import io from "../../io.js";
+
+import DataTable from '../components/DataTable.vue';
+
+export default {
+	components: { DataTable },
+	data: () => {
+		return {
+			accounts: [],
+			fields: [
+				{
+					name: "name",
+					displayName: "Name"
+				},
+				{
+					name: "version",
+					displayName: "Version"
+				},
+				{
+					name: "actions-slot",
+					displayName: "Actions"
+				}
+			],
+			sortOrder: [
+				{
+					field: "name",
+					order: "desc"
+				},
+				{
+					field: "version",
+					order: "asc"
+				}
+			],
+			importConvertSchemaName: ""
+		}
+	},
+	computed: {
+		localData: function() {
+			return this.accounts.map(account => {
+				return {
+					name: account.fields.name[0].name,
+					version: account.version,
+					accountId: account._id
+				};
+			});
+		}
+	},
+	methods: {
+		importConvertSchema() {
+			this.socket.emit("convertSchema.import", this.importConvertSchemaName, (res) => {
+				console.log(res);
+				alert(res.status);
+			});
+		}
+	},
+	mounted() {
+		io.getSocket(socket => {
+			this.socket = socket;
+
+			socket.emit("account.getAll", res => {
+				console.log(res);
+				this.accounts = res.accounts;
+			});
+		});
+	}
+};
+</script>
+
+<style lang="scss" scoped>
+
+</style>