Browse Source

feat: add backup workflows in settings

Ahmad Kholid 3 years ago
parent
commit
f114d0b568

+ 3 - 0
src/assets/css/tailwind.css

@@ -50,6 +50,9 @@ pre {
 .tippy-box[data-theme~='tooltip-theme'] {
   @apply px-2 py-1 bg-gray-900 text-sm text-gray-200 rounded-md;
 }
+.Vue-Toastification__toast {
+  font-family: inherit !important;
+}
 .ProseMirror > * + * {
   margin-top: 0.75em;
 }

+ 186 - 0
src/components/newtab/settings/SettingsBackup.vue

@@ -0,0 +1,186 @@
+<template>
+  <div class="max-w-xl mt-8">
+    <h2 class="font-semibold mb-2">
+      {{ t('settings.backupWorkflows.title') }}
+    </h2>
+    <div class="flex space-x-4">
+      <div class="border p-4 rounded-lg w-6/12">
+        <div class="text-center">
+          <span class="inline-block p-4 rounded-full bg-box-transparent">
+            <v-remixicon name="riDownloadLine" size="36" />
+          </span>
+        </div>
+        <ui-checkbox v-model="state.encrypt" class="mt-12 mb-4">
+          {{ t('settings.backupWorkflows.backup.encrypt') }}
+        </ui-checkbox>
+        <ui-button class="w-full" @click="backupWorkflows">
+          {{ t('settings.backupWorkflows.backup.button') }}
+        </ui-button>
+      </div>
+      <div class="border p-4 rounded-lg w-6/12">
+        <div class="text-center">
+          <span class="inline-block p-4 rounded-full bg-box-transparent">
+            <v-remixicon name="riUploadLine" size="36" />
+          </span>
+        </div>
+        <ui-checkbox v-model="state.updateIfExists" class="mt-6 mb-4">
+          {{ t('settings.backupWorkflows.restore.update') }}
+        </ui-checkbox>
+        <ui-button class="w-full" @click="restoreWorkflows">
+          {{ t('settings.backupWorkflows.restore.button') }}
+        </ui-button>
+      </div>
+    </div>
+  </div>
+</template>
+<script setup>
+import { shallowReactive } from 'vue';
+import { useI18n } from 'vue-i18n';
+import { useToast } from 'vue-toastification';
+import dayjs from 'dayjs';
+import AES from 'crypto-js/aes';
+import encUtf8 from 'crypto-js/enc-utf8';
+import hmacSHA256 from 'crypto-js/hmac-sha256';
+import { useDialog } from '@/composable/dialog';
+import { fileSaver, openFilePicker, parseJSON } from '@/utils/helper';
+import Workflow from '@/models/workflow';
+
+const { t } = useI18n();
+const toast = useToast();
+const dialog = useDialog();
+
+const state = shallowReactive({
+  encrypt: false,
+  updateIfExists: false,
+});
+
+function backupWorkflows() {
+  const workflows = Workflow.all().reduce((acc, workflow) => {
+    if (workflow.isProtected) return acc;
+
+    delete workflow.$id;
+    delete workflow.createdAt;
+    delete workflow.data;
+    delete workflow.isDisabled;
+    delete workflow.isProtected;
+
+    acc.push(workflow);
+
+    return acc;
+  }, []);
+  const payload = {
+    isProtected: state.encrypt,
+    workflows: JSON.stringify(workflows),
+  };
+  const downloadFile = (data) => {
+    const fileName = `automa-${dayjs().format('DD-MM-YYYY')}.json`;
+    const blob = new Blob([JSON.stringify(data)], {
+      type: 'application/json',
+    });
+    const objectUrl = URL.createObjectURL(blob);
+
+    fileSaver(fileName, objectUrl);
+
+    URL.revokeObjectURL(objectUrl);
+  };
+
+  if (state.encrypt) {
+    dialog.prompt({
+      placeholder: t('common.password'),
+      title: t('settings.backupWorkflows.title'),
+      okText: t('settings.backupWorkflows.backup.button'),
+      inputType: 'password',
+      onConfirm: (password) => {
+        const encryptedWorkflows = AES.encrypt(
+          payload.workflows,
+          password
+        ).toString();
+        const hmac = hmacSHA256(encryptedWorkflows, password).toString();
+
+        payload.workflows = hmac + encryptedWorkflows;
+
+        downloadFile(payload);
+      },
+    });
+  } else {
+    downloadFile(payload);
+  }
+}
+async function restoreWorkflows() {
+  try {
+    const file = await openFilePicker('application/json');
+    const reader = new FileReader();
+    const insertWorkflows = (workflows) => {
+      const newWorkflows = workflows.map((workflow) => {
+        if (!state.updateIfExists) delete workflow.id;
+
+        workflow.createdAt = Date.now();
+
+        return workflow;
+      });
+      const showMessage = (event) => {
+        toast(
+          t('settings.backupWorkflows.workflowsAdded', {
+            count: event.workflows.length,
+          })
+        );
+      };
+
+      if (state.updateIfExists) {
+        return Workflow.insertOrUpdate({
+          data: newWorkflows,
+        }).then(showMessage);
+      }
+
+      return Workflow.insert({
+        data: newWorkflows,
+      }).then(showMessage);
+    };
+
+    reader.onload = ({ target }) => {
+      const payload = parseJSON(target.result, null);
+
+      if (!payload) return;
+
+      if (payload.isProtected) {
+        dialog.prompt({
+          placeholder: t('common.password'),
+          title: t('settings.backupWorkflows.restore.title'),
+          okText: t('settings.backupWorkflows.restore.button'),
+          inputType: 'password',
+          onConfirm: (password) => {
+            const hmac = payload.workflows.substring(0, 64);
+            const encryptedWorkflows = payload.workflows.substring(64);
+            const decryptedHmac = hmacSHA256(
+              encryptedWorkflows,
+              password
+            ).toString();
+
+            if (hmac !== decryptedHmac) {
+              toast.error(t('settings.backupWorkflows.invalidPassword'));
+
+              return;
+            }
+
+            const decryptedWorkflows = AES.decrypt(
+              encryptedWorkflows,
+              password
+            ).toString(encUtf8);
+            payload.workflows = parseJSON(decryptedWorkflows, []);
+
+            insertWorkflows(payload.workflows);
+          },
+        });
+      } else {
+        payload.workflows = parseJSON(payload.workflows, []);
+        insertWorkflows(payload.workflows);
+      }
+    };
+
+    reader.readAsText(file);
+  } catch (error) {
+    console.error(error);
+    toast.error(error.message);
+  }
+}
+</script>

+ 17 - 1
src/components/ui/UiDialog.vue

@@ -16,8 +16,21 @@
       autofocus
       :placeholder="state.options.placeholder"
       :label="state.options.label"
+      :type="
+        state.options.inputType === 'password' && state.showPassword
+          ? 'text'
+          : state.options.inputType
+      "
       class="w-full"
-    ></ui-input>
+    >
+      <template v-if="state.options.inputType === 'password'" #append>
+        <v-remixicon
+          :name="state.showPassword ? 'riEyeOffLine' : 'riEyeLine'"
+          class="absolute right-2"
+          @click="state.showPassword = !state.showPassword"
+        />
+      </template>
+    </ui-input>
     <div class="mt-8 flex space-x-2">
       <ui-button class="w-6/12" @click="fireCallback('onCancel')">
         {{ state.options.cancelText }}
@@ -47,6 +60,7 @@ export default {
       title: '',
       placeholder: '',
       label: '',
+      inputType: 'text',
       okText: t('common.confirm'),
       okVariant: 'accent',
       cancelText: t('common.cancel'),
@@ -57,6 +71,7 @@ export default {
       show: false,
       type: '',
       input: '',
+      showPassword: false,
       options: defaultOptions,
     });
 
@@ -84,6 +99,7 @@ export default {
 
       if (hide) {
         state.options = defaultOptions;
+        state.showPassword = false;
         state.show = false;
         state.input = '';
       }

+ 14 - 0
src/locales/en/newtab.json

@@ -21,6 +21,20 @@
       "general": "General",
       "about": "About"
     },
+    "backupWorkflows": {
+      "title": "Backup Workflows",
+      "invalidPassword": "Invalid password",
+      "workflowsAdded": "{count} workflows have been added",
+      "backup": {
+        "button": "Backup",
+        "encrypt": "Encrypt with password"
+      },
+      "restore": {
+        "title": "Restore workflows",
+        "button": "Restore",
+        "update": "Update if the workflow exists"
+      }
+    }
   },
   "workflow": {
     "import": "Import workflow",

+ 1 - 1
src/models/workflow.js

@@ -24,7 +24,7 @@ class Workflow extends Model {
       isProtected: this.boolean(false),
       version: this.string(''),
       globalData: this.string('[{ "key": "value" }]'),
-      createdAt: this.number(),
+      createdAt: this.number(Date.now()),
       isDisabled: this.boolean(false),
       settings: this.attr({
         blockDelay: 0,

+ 1 - 1
src/newtab/pages/Settings.vue

@@ -25,7 +25,7 @@
           </ui-list-item>
         </router-link>
       </ui-list>
-      <div class="settings-content">
+      <div class="settings-content flex-1">
         <router-view />
       </div>
     </div>

+ 4 - 2
src/newtab/pages/settings/index.vue

@@ -1,9 +1,9 @@
 <template>
   <div class="flex items-center">
     <div id="languages">
+      <p class="font-semibold mb-1">{{ t('settings.language.label') }}</p>
       <ui-select
         :model-value="settings.locale"
-        :label="t('settings.language.label')"
         class="w-80"
         @change="updateLanguage"
       >
@@ -16,7 +16,7 @@
         </option>
       </ui-select>
       <a
-        class="block text-sm text-gray-600 dark:text-gray-200 ml-1"
+        class="block text-gray-600 dark:text-gray-200 ml-1"
         href="https://github.com/Kholid060/automa/wiki/Help-Translate"
         target="_blank"
         rel="noopener"
@@ -28,12 +28,14 @@
       {{ t('settings.language.reloadPage') }}
     </p>
   </div>
+  <settings-backup />
 </template>
 <script setup>
 import { computed, ref } from 'vue';
 import { useStore } from 'vuex';
 import { useI18n } from 'vue-i18n';
 import { supportLocales } from '@/utils/shared';
+import SettingsBackup from '@/components/newtab/settings/SettingsBackup.vue';
 
 const { t } = useI18n();
 const store = useStore();

+ 3 - 1
src/utils/helper.js

@@ -77,7 +77,9 @@ export function openFilePicker(acceptedFileTypes = [], attrs = {}) {
   return new Promise((resolve, reject) => {
     const input = document.createElement('input');
     input.type = 'file';
-    input.accept = acceptedFileTypes.join(',');
+    input.accept = Array.isArray(acceptedFileTypes)
+      ? acceptedFileTypes.join(',')
+      : acceptedFileTypes;
 
     Object.entries(attrs).forEach(([key, value]) => {
       input[key] = value;