@hostlink/nuxt-light 1.37.1 → 1.38.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/module.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "light",
3
3
  "configKey": "light",
4
- "version": "1.37.1",
4
+ "version": "1.38.0",
5
5
  "builder": {
6
6
  "@nuxt/module-builder": "1.0.1",
7
7
  "unbuild": "3.5.0"
package/dist/module.mjs CHANGED
@@ -136,6 +136,11 @@ const routes = [
136
136
  path: "/System/database/process",
137
137
  file: "runtime/pages/System/database/process.vue"
138
138
  },
139
+ {
140
+ name: "System-database-restore",
141
+ path: "/System/database/restore",
142
+ file: "runtime/pages/System/database/restore.vue"
143
+ },
139
144
  {
140
145
  name: "System-database-backup",
141
146
  path: "/System/database/backup",
@@ -2,78 +2,205 @@
2
2
  import { computed, ref } from "vue";
3
3
  import { m, api } from "#imports";
4
4
  import { useI18n } from "vue-i18n";
5
+ import { useQuasar } from "quasar";
5
6
  const { t } = useI18n();
7
+ const $q = useQuasar();
8
+ const loading = ref(true);
9
+ const updating = ref(/* @__PURE__ */ new Set());
6
10
  const fetchApp = async () => {
7
- const { app: app2 } = await api.query({
8
- app: {
9
- permissions: true,
10
- roles: {
11
- name: true,
12
- permissions: true
11
+ try {
12
+ loading.value = true;
13
+ const { app: app2 } = await api.query({
14
+ app: {
15
+ permissions: true,
16
+ roles: {
17
+ name: true,
18
+ permissions: true
19
+ }
13
20
  }
14
- }
15
- });
16
- return app2;
21
+ });
22
+ return app2;
23
+ } catch (error) {
24
+ $q.notify({
25
+ type: "negative",
26
+ message: t("Failed to load permissions")
27
+ });
28
+ throw error;
29
+ } finally {
30
+ loading.value = false;
31
+ }
17
32
  };
18
33
  const app = ref(await fetchApp());
19
- const roles = computed(() => app.value.roles);
34
+ const roles = computed(() => app.value?.roles || []);
20
35
  const columns = computed(() => [
21
- { label: t("Permission"), field: "permission", align: "left" },
36
+ {
37
+ label: t("Permission"),
38
+ field: "permission",
39
+ align: "left",
40
+ style: "font-weight: bold; background-color: rgba(0,0,0,0.02);",
41
+ headerStyle: "font-weight: bold;"
42
+ },
22
43
  ...roles.value.map((role) => ({
23
44
  label: role.name,
24
45
  field: role.name,
25
- align: "left"
46
+ align: "center",
47
+ headerStyle: "font-weight: bold;"
26
48
  }))
27
49
  ]);
28
- const rows = computed(
29
- () => app.value.permissions.map((permission) => {
50
+ const rows = computed(() => {
51
+ if (!app.value?.permissions) return [];
52
+ return app.value.permissions.map((permission) => {
30
53
  const row = { permission };
31
54
  roles.value.forEach((role) => {
32
55
  row[role.name] = role.permissions.includes(permission);
33
56
  });
34
57
  return row;
35
- })
36
- );
58
+ });
59
+ });
37
60
  const onUpdate = async (value, role, permission) => {
38
- if (value) {
39
- await m("addPermission", { value: permission, role });
40
- } else {
41
- await m("removePermission", { value: permission, role });
61
+ const updateKey = `${role}-${permission}`;
62
+ updating.value.add(updateKey);
63
+ try {
64
+ if (value) {
65
+ await m("addPermission", { value: permission, role });
66
+ $q.notify({
67
+ type: "positive",
68
+ message: t("Permission granted successfully")
69
+ });
70
+ } else {
71
+ await m("removePermission", { value: permission, role });
72
+ $q.notify({
73
+ type: "positive",
74
+ message: t("Permission removed successfully")
75
+ });
76
+ }
77
+ app.value = await fetchApp();
78
+ } catch (error) {
79
+ $q.notify({
80
+ type: "negative",
81
+ message: t("Failed to update permission")
82
+ });
83
+ app.value = await fetchApp();
84
+ } finally {
85
+ updating.value.delete(updateKey);
86
+ }
87
+ };
88
+ const toggleAllForRole = async (role, grant) => {
89
+ const updates = [];
90
+ for (const permission of app.value.permissions) {
91
+ const currentState = role.permissions.includes(permission);
92
+ if (currentState !== grant) {
93
+ updates.push({ permission, role: role.name, grant });
94
+ }
95
+ }
96
+ if (updates.length === 0) return;
97
+ try {
98
+ loading.value = true;
99
+ for (const update of updates) {
100
+ if (update.grant) {
101
+ await m("addPermission", { value: update.permission, role: update.role });
102
+ } else {
103
+ await m("removePermission", { value: update.permission, role: update.role });
104
+ }
105
+ }
106
+ app.value = await fetchApp();
107
+ $q.notify({
108
+ type: "positive",
109
+ message: t(grant ? "All permissions granted" : "All permissions removed")
110
+ });
111
+ } catch (error) {
112
+ $q.notify({
113
+ type: "negative",
114
+ message: t("Failed to update permissions")
115
+ });
116
+ } finally {
117
+ loading.value = false;
42
118
  }
43
- app.value = await fetchApp();
44
119
  };
45
120
  const filter = ref("");
121
+ const selectedRole = ref("");
46
122
  const filteredRows = computed(() => {
47
- if (!filter.value) return rows.value;
48
- return rows.value.filter(
49
- (row) => row.permission.toLowerCase().includes(filter.value.toLowerCase())
50
- );
123
+ let filtered = rows.value;
124
+ if (filter.value) {
125
+ filtered = filtered.filter(
126
+ (row) => row.permission.toLowerCase().includes(filter.value.toLowerCase())
127
+ );
128
+ }
129
+ if (selectedRole.value) {
130
+ filtered = filtered.filter((row) => row[selectedRole.value]);
131
+ }
132
+ return filtered;
51
133
  });
52
134
  </script>
53
135
 
54
136
  <template>
55
- <l-page>
56
- <q-table :columns="columns" flat bordered :rows="filteredRows" :pagination="{ rowsPerPage: 0 }" dense>
57
- <template #top-left>
58
- <q-input :color="$light.color" v-model="filter" :placeholder="t('Filter permissions')" dense clearable
59
- outlined>
60
- <template v-slot:append>
61
- <q-icon name="sym_o_search" />
62
- </template>
63
- </q-input>
137
+ <l-page>
138
+
139
+ <!-- 載入指示器 -->
140
+ <div v-if="loading" class="text-center q-pa-md">
141
+ <q-spinner-dots size="50px" :color="$light.color" />
142
+ <div class="q-mt-sm">{{ $t('Loading permissions...') }}</div>
143
+ </div>
144
+
145
+ <!-- 主要內容 -->
146
+ <div v-else>
147
+ <!-- 篩選控制 -->
148
+ <div class="row q-gutter-md q-mb-md">
149
+ <div class="col-md-6 col-12">
150
+ <q-input :color="$light.color" v-model="filter" :placeholder="$t('Filter permissions')" dense clearable
151
+ outlined>
152
+ <template v-slot:append>
153
+ <q-icon name="sym_o_search" />
64
154
  </template>
65
- <template #body="props">
66
- <q-tr :props="props">
67
- <q-td>
68
- {{ props.row.permission }}
69
- </q-td>
70
- <q-td v-for="role in roles" :key="role.name">
71
- <q-checkbox v-model="props.row[role.name]"
72
- @update:model-value="onUpdate($event, role.name, props.row.permission)"
73
- :color="$light.color" />
74
- </q-td>
75
- </q-tr>
76
- </template>
77
- </q-table>
78
- </l-page>
155
+ </q-input>
156
+ </div>
157
+ <div class="col-md-6 col-12">
158
+ <q-select :color="$light.color" v-model="selectedRole"
159
+ :options="[{ label: $t('All Roles'), value: '' }, ...roles.map(r => ({ label: r.name, value: r.name }))]"
160
+ :placeholder="$t('Filter by role')" dense clearable outlined emit-value map-options />
161
+ </div>
162
+ </div>
163
+
164
+ <!-- 權限表格 -->
165
+ <q-table :columns="columns" flat bordered :rows="filteredRows" :pagination="{ rowsPerPage: 0 }" dense
166
+ class="permission-table">
167
+
168
+ <template #body="props">
169
+ <q-tr :props="props">
170
+ <q-td :style="columns[0].style" class="text-weight-medium">
171
+ <q-icon name="security" class="q-mr-sm text-grey-6" />
172
+ {{ props.row.permission }}
173
+ </q-td>
174
+ <q-td v-for="role in roles" :key="role.name" class="text-center">
175
+ <q-checkbox v-model="props.row[role.name]"
176
+ @update:model-value="onUpdate($event, role.name, props.row.permission)" :color="$light.color"
177
+ :loading="updating.has(`${role.name}-${props.row.permission}`)"
178
+ :disable="updating.has(`${role.name}-${props.row.permission}`)" />
179
+ </q-td>
180
+ </q-tr>
181
+ </template>
182
+
183
+ <template #no-data>
184
+ <div class="full-width text-center q-pa-lg">
185
+ <q-icon name="search_off" size="48px" class="text-grey-5" />
186
+ <div class="text-h6 text-grey-7 q-mt-md">
187
+ {{ $t('No permissions found') }}
188
+ </div>
189
+ <div class="text-body2 text-grey-6">
190
+ {{ $t('Try adjusting your search filters') }}
191
+ </div>
192
+ </div>
193
+ </template>
194
+ </q-table>
195
+
196
+ <!-- 結果統計 -->
197
+ <div class="q-mt-md text-caption text-grey-7">
198
+ {{ $t('Showing') }} {{ filteredRows.length }} {{ $t('of') }} {{ rows.length }} {{ $t('permissions') }}
199
+ </div>
200
+ </div>
201
+ </l-page>
79
202
  </template>
203
+
204
+ <style scoped>
205
+ .permission-table{box-shadow:0 2px 8px rgba(0,0,0,.1)}.permission-table :deep(.q-table__top){background-color:rgba(0,0,0,.02);padding:16px}.permission-table :deep(thead tr th){background-color:rgba(0,0,0,.05);font-weight:700}.permission-table :deep(tbody tr:hover){background-color:rgba(0,0,0,.03)}.permission-table :deep(tbody tr td:first-child){border-right:2px solid rgba(0,0,0,.1)}
206
+ </style>
@@ -1,8 +1,28 @@
1
1
  <script setup>
2
2
  import { q } from "#imports";
3
3
  import { Loading, Notify } from "quasar";
4
+ import { ref, onMounted } from "vue";
5
+ const isDownloading = ref(false);
6
+ const databaseInfo = ref(null);
7
+ const fetchDatabaseInfo = async () => {
8
+ try {
9
+ const info = await q({
10
+ system: {
11
+ database: {
12
+ type: true,
13
+ sizeBytes: true,
14
+ version: true
15
+ }
16
+ }
17
+ });
18
+ databaseInfo.value = info.system.database;
19
+ } catch (e) {
20
+ console.error("Failed to fetch database info:", e);
21
+ }
22
+ };
4
23
  const onClickDownload = async () => {
5
- Loading.show({ message: "Exporting database..." });
24
+ isDownloading.value = true;
25
+ Loading.show({ message: "\u6B63\u5728\u532F\u51FA\u8CC7\u6599\u5EAB\uFF0C\u8ACB\u7A0D\u5019..." });
6
26
  try {
7
27
  const data = await q({
8
28
  system: {
@@ -11,23 +31,111 @@ const onClickDownload = async () => {
11
31
  }
12
32
  }
13
33
  });
34
+ const timestamp = (/* @__PURE__ */ new Date()).toISOString().slice(0, 19).replace(/:/g, "-");
35
+ const filename = `database-backup-${timestamp}.sql`;
14
36
  const blob = new Blob([data.system.database.export], { type: "text/plain;charset=utf-8" });
15
37
  const url = URL.createObjectURL(blob);
16
38
  const a = document.createElement("a");
17
- a.download = "backup.sql";
39
+ a.download = filename;
18
40
  a.href = url;
19
41
  a.click();
20
42
  setTimeout(() => URL.revokeObjectURL(url), 1e3);
43
+ Notify.create({
44
+ type: "positive",
45
+ message: "\u8CC7\u6599\u5EAB\u5099\u4EFD\u5DF2\u6210\u529F\u4E0B\u8F09\uFF01",
46
+ position: "top"
47
+ });
21
48
  } catch (e) {
22
- Notify.create({ type: "negative", message: "\u4E0B\u8F09\u5931\u6557\uFF0C\u8ACB\u7A0D\u5F8C\u518D\u8A66\u3002" });
49
+ Notify.create({
50
+ type: "negative",
51
+ message: "\u4E0B\u8F09\u5931\u6557\uFF0C\u8ACB\u7A0D\u5F8C\u518D\u8A66\u3002",
52
+ position: "top"
53
+ });
23
54
  } finally {
24
55
  Loading.hide();
56
+ isDownloading.value = false;
25
57
  }
26
58
  };
59
+ onMounted(() => {
60
+ fetchDatabaseInfo();
61
+ });
27
62
  </script>
28
63
 
29
64
  <template>
30
- <l-page title="Database backup">
31
- <l-btn label="Download" icon="sym_o_download" @click="onClickDownload"></l-btn>
65
+ <l-page title="資料庫備份">
66
+ <div class="backup-container">
67
+ <!-- 頁面說明 -->
68
+ <l-card class="info-card">
69
+ <div class="card-header">
70
+ <h5 class="card-title">
71
+ <q-icon name="sym_o_info" class="text-primary" />
72
+ 備份說明
73
+ </h5>
74
+ </div>
75
+ <div class="card-content">
76
+ <p class="description">
77
+ 資料庫備份功能可以將整個資料庫匯出為 SQL 檔案,包含所有資料表結構和資料。
78
+ 建議定期進行備份以確保資料安全。
79
+ </p>
80
+ </div>
81
+ </l-card>
82
+
83
+ <!-- 備份資訊 -->
84
+ <l-card class="status-card">
85
+ <div class="card-header">
86
+ <h5 class="card-title">
87
+ <q-icon name="sym_o_database" class="text-secondary" />
88
+ 資料庫資訊
89
+ </h5>
90
+ </div>
91
+ <div class="card-content">
92
+ <div class="info-grid">
93
+ <div class="info-item" v-if="databaseInfo">
94
+ <q-icon name="sym_o_storage" class="info-icon" />
95
+ <div class="info-content">
96
+ <span class="info-label">資料庫類型</span>
97
+ <span class="info-value">{{ databaseInfo.type || '未知' }}</span>
98
+ </div>
99
+ </div>
100
+ <div class="info-item" v-if="databaseInfo">
101
+ <q-icon name="sym_o_storage" class="info-icon" />
102
+ <div class="info-content">
103
+ <span class="info-label">資料庫大小</span>
104
+ <span class="info-value">{{ databaseInfo.sizeBytes ? (databaseInfo.sizeBytes / 1024 /
105
+ 1024).toFixed(2) + ' MB' : '計算中...' }}</span>
106
+ </div>
107
+ </div>
108
+ <div class="info-item" v-if="databaseInfo && databaseInfo.version">
109
+ <q-icon name="sym_o_info" class="info-icon" />
110
+ <div class="info-content">
111
+ <span class="info-label">資料庫版本</span>
112
+ <span class="info-value">{{ databaseInfo.version }}</span>
113
+ </div>
114
+ </div>
115
+ </div>
116
+ </div>
117
+ </l-card>
118
+
119
+ <!-- 安全提醒 -->
120
+ <l-alert type="warning" class="security-alert">
121
+ <template #prefix>
122
+ <q-icon name="sym_o_security" />
123
+ </template>
124
+ <strong>安全提醒:</strong>備份檔案包含敏感資料,請妥善保管並避免在不安全的環境中傳輸。
125
+ </l-alert>
126
+
127
+ <!-- 下載按鈕區域 -->
128
+ <div class="download-section">
129
+ <l-btn :label="isDownloading ? '正在備份中...' : '開始備份'" icon="sym_o_download" color="primary" size="lg"
130
+ :loading="isDownloading" :disable="isDownloading" @click="onClickDownload" class="download-btn" />
131
+ <p class="download-hint">
132
+ 點擊按鈕將會下載包含完整資料庫內容的 SQL 檔案
133
+ </p>
134
+ </div>
135
+ </div>
32
136
  </l-page>
33
137
  </template>
138
+
139
+ <style scoped>
140
+ .backup-container{display:flex;flex-direction:column;gap:24px;margin:0 auto;max-width:800px;padding:20px}.info-card,.status-card{border-radius:12px;box-shadow:0 2px 8px rgba(0,0,0,.1)}.card-header{padding:20px 24px 0}.card-title{align-items:center;color:#2c3e50;display:flex;font-weight:600;gap:8px;margin:0}.card-content{padding:16px 24px 24px}.description{color:#5a6c7d;line-height:1.6;margin:0}.info-grid{display:grid;gap:16px}.info-item{align-items:center;background:#f8f9fa;border-radius:8px;display:flex;gap:12px;padding:12px}.info-icon{color:#6c757d;font-size:24px}.info-content{display:flex;flex-direction:column;gap:4px}.info-label{color:#6c757d;font-size:14px;font-weight:500}.info-value{color:#2c3e50;font-size:16px;font-weight:600}.security-alert{border-radius:8px}.download-section{background:linear-gradient(135deg,#f8f9fa,#e9ecef);border:2px dashed #dee2e6;border-radius:16px;padding:32px 24px;text-align:center}.download-btn{border-radius:8px;box-shadow:0 4px 12px rgba(0,123,255,.3);font-size:16px;font-weight:600;height:48px;margin-bottom:12px;min-width:200px;transition:all .3s ease}.download-btn:hover{box-shadow:0 6px 20px rgba(0,123,255,.4);transform:translateY(-2px)}.download-hint{color:#6c757d;font-size:14px;margin:0 auto;max-width:400px}@media (max-width:768px){.backup-container{gap:20px;padding:16px}.card-content{padding:12px 20px 20px}.download-section{padding:24px 20px}.download-btn{min-width:100%}}
141
+ </style>
@@ -0,0 +1,207 @@
1
+ <script setup>
2
+ import { q, m } from "#imports";
3
+ import { Loading, Notify } from "quasar";
4
+ import { ref, onMounted } from "vue";
5
+ const isUploading = ref(false);
6
+ const selectedFile = ref(null);
7
+ const fileInput = ref(null);
8
+ const databaseInfo = ref(null);
9
+ const fetchDatabaseInfo = async () => {
10
+ try {
11
+ const info = await q({
12
+ system: {
13
+ database: {
14
+ type: true,
15
+ sizeBytes: true,
16
+ version: true
17
+ }
18
+ }
19
+ });
20
+ databaseInfo.value = info.system.database;
21
+ } catch (e) {
22
+ console.error("Failed to fetch database info:", e);
23
+ }
24
+ };
25
+ const onFileChange = (event) => {
26
+ const file = event.target.files[0];
27
+ if (file) {
28
+ if (file.type === "application/sql" || file.name.endsWith(".sql")) {
29
+ selectedFile.value = file;
30
+ } else {
31
+ Notify.create({
32
+ type: "negative",
33
+ message: "\u8ACB\u9078\u64C7 SQL \u6A94\u6848 (.sql)",
34
+ position: "top"
35
+ });
36
+ event.target.value = "";
37
+ }
38
+ }
39
+ };
40
+ const clearFile = () => {
41
+ selectedFile.value = null;
42
+ if (fileInput.value) {
43
+ fileInput.value.value = "";
44
+ }
45
+ };
46
+ const onClickRestore = async () => {
47
+ if (!selectedFile.value) {
48
+ Notify.create({
49
+ type: "negative",
50
+ message: "\u8ACB\u5148\u9078\u64C7\u8981\u9084\u539F\u7684 SQL \u6A94\u6848",
51
+ position: "top"
52
+ });
53
+ return;
54
+ }
55
+ isUploading.value = true;
56
+ Loading.show({ message: "\u6B63\u5728\u9084\u539F\u8CC7\u6599\u5EAB\uFF0C\u8ACB\u7A0D\u5019..." });
57
+ try {
58
+ const result = await m("restoreDatabase", {
59
+ file: selectedFile.value
60
+ });
61
+ Notify.create({
62
+ type: "positive",
63
+ message: "\u8CC7\u6599\u5EAB\u9084\u539F\u6210\u529F\uFF01",
64
+ position: "top"
65
+ });
66
+ clearFile();
67
+ await fetchDatabaseInfo();
68
+ } catch (e) {
69
+ console.error("Restore failed:", e);
70
+ Notify.create({
71
+ type: "negative",
72
+ message: "\u9084\u539F\u5931\u6557\uFF0C\u8ACB\u6AA2\u67E5\u6A94\u6848\u683C\u5F0F\u6216\u7A0D\u5F8C\u518D\u8A66\u3002",
73
+ position: "top"
74
+ });
75
+ } finally {
76
+ Loading.hide();
77
+ isUploading.value = false;
78
+ }
79
+ };
80
+ const formatFileSize = (bytes) => {
81
+ if (bytes === 0) return "0 B";
82
+ const k = 1024;
83
+ const sizes = ["B", "KB", "MB", "GB"];
84
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
85
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
86
+ };
87
+ onMounted(() => {
88
+ fetchDatabaseInfo();
89
+ });
90
+ </script>
91
+
92
+ <template>
93
+ <l-page title="資料庫還原">
94
+ <div class="import-container">
95
+ <!-- 頁面說明 -->
96
+ <l-card class="info-card">
97
+ <div class="card-header">
98
+ <h5 class="card-title">
99
+ <q-icon name="sym_o_info" class="text-primary" />
100
+ 還原說明
101
+ </h5>
102
+ </div>
103
+ <div class="card-content">
104
+ <p class="description">
105
+ 資料庫還原功能可以從 SQL 備份檔案恢復資料庫內容。
106
+ 請確保上傳的 SQL 檔案來源可信且格式正確。
107
+ </p>
108
+ </div>
109
+ </l-card>
110
+
111
+ <!-- 當前資料庫資訊 -->
112
+ <l-card class="status-card">
113
+ <div class="card-header">
114
+ <h5 class="card-title">
115
+ <q-icon name="sym_o_database" class="text-secondary" />
116
+ 當前資料庫資訊
117
+ </h5>
118
+ </div>
119
+ <div class="card-content">
120
+ <div class="info-grid">
121
+ <div class="info-item" v-if="databaseInfo">
122
+ <q-icon name="sym_o_storage" class="info-icon" />
123
+ <div class="info-content">
124
+ <span class="info-label">資料庫類型</span>
125
+ <span class="info-value">{{ databaseInfo.type || '未知' }}</span>
126
+ </div>
127
+ </div>
128
+ <div class="info-item" v-if="databaseInfo">
129
+ <q-icon name="sym_o_storage" class="info-icon" />
130
+ <div class="info-content">
131
+ <span class="info-label">當前大小</span>
132
+ <span class="info-value">{{ databaseInfo.sizeBytes ? (databaseInfo.sizeBytes / 1024 /
133
+ 1024).toFixed(2) + ' MB' : '計算中...' }}</span>
134
+ </div>
135
+ </div>
136
+ <div class="info-item" v-if="databaseInfo && databaseInfo.version">
137
+ <q-icon name="sym_o_info" class="info-icon" />
138
+ <div class="info-content">
139
+ <span class="info-label">資料庫版本</span>
140
+ <span class="info-value">{{ databaseInfo.version }}</span>
141
+ </div>
142
+ </div>
143
+ </div>
144
+ </div>
145
+ </l-card>
146
+
147
+ <!-- 安全警告 -->
148
+ <l-alert type="negative" class="security-alert">
149
+ <template #prefix>
150
+ <q-icon name="sym_o_warning" />
151
+ </template>
152
+ <strong>重要警告:</strong>還原資料庫將會完全覆蓋現有資料,此操作無法復原。建議在還原前先進行當前資料庫的備份。
153
+ </l-alert>
154
+
155
+ <!-- 檔案上傳區域 -->
156
+ <l-card class="upload-card">
157
+ <div class="card-header">
158
+ <h5 class="card-title">
159
+ <q-icon name="sym_o_upload" class="text-primary" />
160
+ 選擇備份檔案
161
+ </h5>
162
+ </div>
163
+ <div class="card-content">
164
+ <div class="upload-area" :class="{ 'has-file': selectedFile }">
165
+ <input ref="fileInput" type="file" accept=".sql,application/sql" @change="onFileChange"
166
+ class="file-input" id="sql-file-input" />
167
+
168
+ <div v-if="!selectedFile" class="upload-placeholder">
169
+ <q-icon name="sym_o_cloud_upload" class="upload-icon" />
170
+ <p class="upload-text">點擊選擇 SQL 檔案</p>
171
+ <p class="upload-hint">支援 .sql 格式的檔案</p>
172
+ <label for="sql-file-input" class="upload-button">
173
+ <l-btn label="選擇檔案" icon="sym_o_folder_open" color="primary" />
174
+ </label>
175
+ </div>
176
+
177
+ <div v-else class="file-info">
178
+ <div class="file-details">
179
+ <q-icon name="sym_o_description" class="file-icon" />
180
+ <div class="file-content">
181
+ <span class="file-name">{{ selectedFile.name }}</span>
182
+ <span class="file-size">{{ formatFileSize(selectedFile.size) }}</span>
183
+ </div>
184
+ </div>
185
+ <l-btn icon="sym_o_close" color="negative" flat round @click="clearFile"
186
+ class="remove-btn" />
187
+ </div>
188
+ </div>
189
+ </div>
190
+ </l-card>
191
+
192
+ <!-- 還原按鈕區域 -->
193
+ <div class="restore-section">
194
+ <l-btn :label="isUploading ? '正在還原中...' : '開始還原資料庫'" icon="sym_o_restore" color="primary" size="lg"
195
+ :loading="isUploading" :disabled="isUploading || !selectedFile" @click="onClickRestore"
196
+ class="restore-btn" />
197
+ <p class="restore-hint">
198
+ 請確認已備份當前資料庫,還原操作將無法復原
199
+ </p>
200
+ </div>
201
+ </div>
202
+ </l-page>
203
+ </template>
204
+
205
+ <style scoped>
206
+ .import-container{display:flex;flex-direction:column;gap:24px;margin:0 auto;max-width:800px;padding:20px}.info-card,.status-card,.upload-card{border-radius:12px;box-shadow:0 2px 8px rgba(0,0,0,.1)}.card-header{padding:20px 24px 0}.card-title{align-items:center;color:#2c3e50;display:flex;font-weight:600;gap:8px;margin:0}.card-content{padding:16px 24px 24px}.description{color:#5a6c7d;line-height:1.6;margin:0}.info-grid{display:grid;gap:16px}.info-item{align-items:center;background:#f8f9fa;border-radius:8px;display:flex;gap:12px;padding:12px}.info-icon{color:#6c757d;font-size:24px}.info-content{display:flex;flex-direction:column;gap:4px}.info-label{color:#6c757d;font-size:14px;font-weight:500}.info-value{color:#2c3e50;font-size:16px;font-weight:600}.security-alert{border-radius:8px}.upload-area{background:#fafbfc;border:2px dashed #dee2e6;border-radius:12px;min-height:200px;position:relative;transition:all .3s ease}.upload-area:hover{background:#f8f9ff;border-color:#007bff}.upload-area.has-file{background:#f8fff9;border-color:#28a745}.file-input{cursor:pointer;height:100%;opacity:0;position:absolute;width:100%;z-index:1}.upload-placeholder{align-items:center;display:flex;flex-direction:column;height:200px;justify-content:center;text-align:center}.upload-icon{color:#007bff;font-size:64px;margin-bottom:16px}.upload-text{color:#2c3e50;font-size:18px;font-weight:600;margin:0 0 8px}.upload-hint{color:#6c757d;font-size:14px;margin:0 0 20px}.upload-button{cursor:pointer;position:relative;z-index:2}.file-info{height:200px;justify-content:space-between;padding:20px}.file-details,.file-info{align-items:center;display:flex}.file-details{gap:16px}.file-icon{color:#28a745;font-size:48px}.file-content{display:flex;flex-direction:column;gap:4px}.file-name{color:#2c3e50;font-size:16px;font-weight:600}.file-size{color:#6c757d;font-size:14px}.remove-btn{flex-shrink:0}.restore-section{background:linear-gradient(135deg,#fff3cd,#ffeeba);border:2px solid #ffc107;border-radius:16px;padding:32px 24px;text-align:center}.restore-btn{border-radius:8px;box-shadow:0 4px 12px rgba(255,193,7,.3);font-size:16px;font-weight:600;height:48px;margin-bottom:12px;min-width:240px;transition:all .3s ease}.restore-btn:hover:not(:disabled){box-shadow:0 6px 20px rgba(255,193,7,.4);transform:translateY(-2px)}.restore-btn:disabled{opacity:.6}.restore-hint{color:#856404;font-size:14px;font-weight:500;margin:0 auto;max-width:400px}@media (max-width:768px){.import-container{gap:20px;padding:16px}.card-content{padding:12px 20px 20px}.upload-area{min-height:160px}.file-info,.upload-placeholder{height:160px}.file-info{padding:16px}.restore-section{padding:24px 20px}.restore-btn{min-width:100%}}
207
+ </style>
@@ -0,0 +1,2 @@
1
+ declare const _default: import("vue").DefineComponent<{}, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<{}> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
2
+ export default _default;
@@ -7,6 +7,7 @@ const { data, refresh } = await useAsyncData("database", async () => {
7
7
  system: {
8
8
  database: {
9
9
  table: true,
10
+ type: true,
10
11
  version: true,
11
12
  tableStatus: true
12
13
  }
@@ -171,6 +172,7 @@ const truncatTable = async () => {
171
172
 
172
173
  <l-card>
173
174
  <l-list>
175
+ <l-item label="Type">{{ data.type }}</l-item>
174
176
  <l-item label="Version">{{ data.version }}</l-item>
175
177
  </l-list>
176
178
  </l-card>
@@ -1,10 +1,9 @@
1
1
  <script setup>
2
2
  import { reactive, onMounted, nextTick } from "vue";
3
3
  import { useQuasar } from "quasar";
4
- import { q, m, useLight, api } from "#imports";
5
- import { useI18n } from "vue-i18n";
4
+ import { q, useLight, api } from "#imports";
5
+ import { fabGoogle, fabFacebook, fabMicrosoft } from "@quasar/extras/fontawesome-v6";
6
6
  const light = useLight();
7
- const { t } = useI18n();
8
7
  const quasar = useQuasar();
9
8
  let { app, my } = await q({
10
9
  app: {
@@ -139,78 +138,169 @@ const onUnlinkFacebook = async () => {
139
138
  </script>
140
139
 
141
140
  <template>
142
- <q-card :bordered="false">
143
- <q-card-section>
144
- <h2 class="text-h6">Google</h2>
145
- <template v-if="app.googleClientId">
146
- <template v-if="my.google">
141
+ <div class="q-pa-md">
142
+ <!-- 頁面標題 -->
143
+ <div class="q-mb-lg">
144
+ <h1 class="text-h4 q-mb-sm">{{ $t('Social Account Linking') }}</h1>
145
+ <p class="text-grey-6">{{ $t('Link your social accounts to enable quick sign-in and enhanced features.') }}
146
+ </p>
147
+ </div>
147
148
 
148
- {{ $t('You have already linked your Google account.') }}<br />
149
- Your gmail id is {{ my.google }}<br />
150
- <l-btn label="Unlink" @click="onUnlink" icon="sym_o_link_off"></l-btn>
149
+ <!-- 社交平台連結區域 -->
150
+ <div class="row q-col-gutter-md">
151
+ <!-- Google -->
152
+ <div class="col-12 col-md-4">
153
+ <q-card class="full-height" :class="my.google ? 'bg-green-1 border-green' : 'bg-grey-1'">
154
+ <q-card-section class="text-center q-pb-none">
155
+ <q-avatar size="64px" class="q-mb-md" :color="my.google ? 'green' : 'grey-5'"
156
+ text-color="white">
157
+ <q-icon :name="fabGoogle" size="32px" />
158
+ </q-avatar>
159
+ <div class="text-h6 q-mb-xs">Google</div>
160
+ <q-badge v-if="my.google" color="green" class="q-mb-md">
161
+ {{ $t('Connected') }}
162
+ </q-badge>
163
+ <q-badge v-else color="grey-5" class="q-mb-md">
164
+ {{ $t('Not Connected') }}
165
+ </q-badge>
166
+ </q-card-section>
151
167
 
152
- </template>
168
+ <q-card-section v-if="app.googleClientId">
169
+ <template v-if="my.google">
170
+ <div class="text-center q-mb-md">
171
+ <div class="text-caption text-grey-6">{{ $t('Connected Account') }}</div>
172
+ <div class="text-body2 text-weight-medium q-mt-xs">{{ my.google }}</div>
173
+ </div>
174
+ <q-btn class="full-width" color="negative" outline @click="onUnlink" icon="sym_o_link_off"
175
+ :label="$t('Unlink Account')" />
176
+ </template>
177
+ <template v-else>
178
+ <div class="text-center q-mb-md text-grey-6">
179
+ {{ $t('Click to link your Google account for quick sign-in') }}
180
+ </div>
181
+ <div id="g_id_signin" class="flex justify-center"></div>
182
+ </template>
183
+ </q-card-section>
184
+ <q-card-section v-else>
185
+ <q-banner class="bg-orange-2 text-orange-9">
186
+ <template v-slot:avatar>
187
+ <q-icon name="sym_o_warning" />
188
+ </template>
189
+ {{ $t('Service not configured. Please contact administrator.') }}
190
+ </q-banner>
191
+ </q-card-section>
192
+ </q-card>
193
+ </div>
153
194
 
154
- <template v-else>
155
- {{ $t('Click the button below to link your Google account.') }}
156
- <div id="g_id_signin"></div>
157
- </template>
158
- </template>
195
+ <!-- Microsoft -->
196
+ <div class="col-12 col-md-4">
197
+ <q-card class="full-height" :class="my.microsoft ? 'bg-blue-1 border-blue' : 'bg-grey-1'">
198
+ <q-card-section class="text-center q-pb-none">
199
+ <q-avatar size="64px" class="q-mb-md" :color="my.microsoft ? 'blue' : 'grey-5'"
200
+ text-color="white">
201
+ <q-icon :name="fabMicrosoft" size="32px" />
202
+ </q-avatar>
203
+ <div class="text-h6 q-mb-xs">Microsoft</div>
204
+ <q-badge v-if="my.microsoft" color="blue" class="q-mb-md">
205
+ {{ $t('Connected') }}
206
+ </q-badge>
207
+ <q-badge v-else color="grey-5" class="q-mb-md">
208
+ {{ $t('Not Connected') }}
209
+ </q-badge>
210
+ </q-card-section>
159
211
 
160
- <template v-else>
161
- Google login is not available. Please set authentication google client id in server settings.
162
- </template>
163
- </q-card-section>
212
+ <q-card-section v-if="app.microsoftClientId">
213
+ <template v-if="my.microsoft">
214
+ <div class="text-center q-mb-md">
215
+ <div class="text-caption text-grey-6">{{ $t('Connected Account') }}</div>
216
+ <div class="text-body2 text-weight-medium q-mt-xs">{{ my.microsoft }}</div>
217
+ </div>
218
+ <q-btn class="full-width" color="negative" outline @click="onUnlinkMicrosoft"
219
+ icon="sym_o_link_off" :label="$t('Unlink Account')" />
220
+ </template>
221
+ <template v-else>
222
+ <div class="text-center q-mb-md text-grey-6">
223
+ {{ $t('Click to link your Microsoft account for quick sign-in') }}
224
+ </div>
225
+ <div class="flex justify-center">
226
+ <l-microsoft-button :client-id="app.microsoftClientId"
227
+ :tenant-id="app.microsoftTenantId" @login="onLinkMicrosoft" />
228
+ </div>
229
+ </template>
230
+ </q-card-section>
231
+ <q-card-section v-else>
232
+ <q-banner class="bg-orange-2 text-orange-9">
233
+ <template v-slot:avatar>
234
+ <q-icon name="sym_o_warning" />
235
+ </template>
236
+ {{ $t('Service not configured. Please contact administrator.') }}
237
+ </q-banner>
238
+ </q-card-section>
239
+ </q-card>
240
+ </div>
164
241
 
242
+ <!-- Facebook -->
243
+ <div class="col-12 col-md-4">
244
+ <q-card class="full-height" :class="my.facebook ? 'bg-indigo-1 border-indigo' : 'bg-grey-1'">
245
+ <q-card-section class="text-center q-pb-none">
246
+ <q-avatar size="64px" class="q-mb-md" :color="my.facebook ? 'indigo' : 'grey-5'"
247
+ text-color="white">
248
+ <q-icon :name="fabFacebook" size="32px" />
249
+ </q-avatar>
250
+ <div class="text-h6 q-mb-xs">Facebook</div>
251
+ <q-badge v-if="my.facebook" color="indigo" class="q-mb-md">
252
+ {{ $t('Connected') }}
253
+ </q-badge>
254
+ <q-badge v-else color="grey-5" class="q-mb-md">
255
+ {{ $t('Not Connected') }}
256
+ </q-badge>
257
+ </q-card-section>
165
258
 
166
- <q-separator />
259
+ <q-card-section v-if="app.facebookAppId">
260
+ <template v-if="my.facebook">
261
+ <div class="text-center q-mb-md">
262
+ <div class="text-caption text-grey-6">{{ $t('Connected Account') }}</div>
263
+ <div class="text-body2 text-weight-medium q-mt-xs">{{ my.facebook }}</div>
264
+ </div>
265
+ <q-btn class="full-width" color="negative" outline @click="onUnlinkFacebook"
266
+ icon="sym_o_link_off" :label="$t('Unlink Account')" />
267
+ </template>
268
+ <template v-else>
269
+ <div class="text-center q-mb-md text-grey-6">
270
+ {{ $t('Click to link your Facebook account for quick sign-in') }}
271
+ </div>
272
+ <div class="flex justify-center">
273
+ <l-facebook-button @login="onLinkFacebook" />
274
+ </div>
275
+ </template>
276
+ </q-card-section>
277
+ <q-card-section v-else>
278
+ <q-banner class="bg-orange-2 text-orange-9">
279
+ <template v-slot:avatar>
280
+ <q-icon name="sym_o_warning" />
281
+ </template>
282
+ {{ $t('Service not configured. Please contact administrator.') }}
283
+ </q-banner>
284
+ </q-card-section>
285
+ </q-card>
286
+ </div>
287
+ </div>
167
288
 
168
- <q-card-section>
169
- <h2 class="text-h6">Microsoft</h2>
170
- <template v-if="app.microsoftClientId">
171
- <template v-if="my.microsoft">
172
- {{ $t('You have already linked your Microsoft account.') }}<br />
173
- Your account id is {{ my.microsoft }}
174
- <br />
175
- <l-btn label="Unlink" @click="onUnlinkMicrosoft" icon="sym_o_link_off"></l-btn>
176
- </template>
177
- <template v-else>
178
- <div>{{ $t('Click the button below to link your Microsoft account.') }}</div>
179
- <div>
180
- <l-microsoft-button :client-id="app.microsoftClientId" :tenant-id="app.microsoftTenantId"
181
- @login="onLinkMicrosoft" />
182
- </div>
183
- </template>
184
- </template>
185
- <template v-else>
186
- Micorsoft login is not available. Please set authentication microsft client id in server settings.
187
- </template>
188
- </q-card-section>
189
-
190
-
191
- <q-separator />
192
- <q-card-section>
193
- <h2 class="text-h6">Facebook</h2>
194
- <template v-if="app.facebookAppId">
195
- <template v-if="my.facebook">
196
- {{ $t('You have already linked your Facebook account.') }}<br />
197
- Your account id is {{ my.facebook }}
198
- <l-btn label="Unlink" @click="onUnlinkFacebook" icon="sym_o_link_off"></l-btn>
199
- </template>
200
- <template v-else>
201
- <div>{{ $t('Click the button below to link your Facebook account.') }}</div>
202
- <l-facebook-button @login="onLinkFacebook" />
203
- </template>
204
- </template>
205
- <template v-else>
206
- Facebook login is not available. Please set authentication facebook app id in server settings.
207
- </template>
208
- </q-card-section>
209
-
210
-
211
-
212
-
213
-
214
-
215
- </q-card>
289
+ <!-- 安全提示 -->
290
+ <q-card class="q-mt-lg bg-blue-1">
291
+ <q-card-section>
292
+ <div class="row items-center">
293
+ <q-icon name="sym_o_security" color="blue" size="24px" class="q-mr-sm" />
294
+ <div class="text-h6 text-blue-8">{{ $t('Security Notice') }}</div>
295
+ </div>
296
+ <p class="text-blue-7 q-mb-none q-mt-sm">
297
+ {{ $t('Your social accounts are securely linked using industry-standard OAuth protocols. You can unlink them at any time.') }}
298
+ </p>
299
+ </q-card-section>
300
+ </q-card>
301
+ </div>
216
302
  </template>
303
+
304
+ <style scoped>
305
+ .border-green{border:2px solid #4caf50!important}.border-blue{border:2px solid #2196f3!important}.border-indigo{border:2px solid #3f51b5!important}.q-card{border-radius:12px;transition:all .3s ease}.q-card:hover{box-shadow:0 8px 24px rgba(0,0,0,.12);transform:translateY(-2px)}.q-avatar{transition:all .3s ease}.q-card:hover .q-avatar{transform:scale(1.05)}.full-height{height:100%;min-height:300px}@media (max-width:768px){.full-height{min-height:auto}}
306
+ </style>
@@ -43,13 +43,14 @@ const policies = computed(() => {
43
43
  validation="required|confirm" />
44
44
  </FormKit>
45
45
 
46
- <q-card flat>
46
+ <q-separator spaced />
47
+
48
+ <q-card flat :bordered="false">
47
49
  <q-card-section>
48
50
  <div>{{ $t('Password policy') }}</div>
49
51
  <ul>
50
52
  <li v-for="policy in policies" :key="policy">{{ policy }}</li>
51
53
  </ul>
52
-
53
54
  </q-card-section>
54
55
  </q-card>
55
56
  </template>
@@ -11,81 +11,247 @@ const { my } = await q({
11
11
  const obj = reactive({
12
12
  code: ""
13
13
  });
14
+ const currentStep = ref(1);
15
+ const secretVisible = ref(false);
16
+ const loading = ref(false);
14
17
  const save = async () => {
18
+ loading.value = true;
15
19
  try {
16
20
  await m("updateMy2FA", {
17
21
  code: obj.code,
18
22
  secret: my.my2FA.secret
19
23
  });
20
24
  $q.notify({
21
- message: "Your 2FA is updated",
22
- color: "positive"
25
+ message: "\u{1F389} Two-Factor Authentication has been successfully enabled!",
26
+ color: "positive",
27
+ timeout: 5e3,
28
+ actions: [
29
+ { label: "Dismiss", color: "white" }
30
+ ]
23
31
  });
32
+ await navigateTo("/User/setting");
24
33
  } catch (e) {
25
34
  $q.notify({
26
- message: e.message,
27
- color: "negative"
35
+ message: `\u274C Setup failed: ${e.message}`,
36
+ color: "negative",
37
+ timeout: 5e3,
38
+ actions: [
39
+ { label: "Retry", color: "white" }
40
+ ]
28
41
  });
42
+ } finally {
43
+ loading.value = false;
29
44
  }
30
45
  };
31
46
  const show = ref(true);
32
47
  if (my.twoFactorEnabled) {
33
48
  show.value = false;
49
+ currentStep.value = 3;
34
50
  }
35
- const onCopy = () => {
36
- navigator.clipboard.writeText(my.my2FA.secret).then(() => {
51
+ const onCopy = async () => {
52
+ try {
53
+ await navigator.clipboard.writeText(my.my2FA.secret);
37
54
  $q.notify({
38
- message: "Secret copied",
39
- color: "positive"
55
+ message: "\u2705 Secret key copied to clipboard",
56
+ color: "positive",
57
+ timeout: 2e3,
58
+ icon: "content_copy"
40
59
  });
41
- }, () => {
60
+ } catch (e) {
61
+ const textArea = document.createElement("textarea");
62
+ textArea.value = my.my2FA.secret;
63
+ document.body.appendChild(textArea);
64
+ textArea.select();
65
+ document.execCommand("copy");
66
+ document.body.removeChild(textArea);
42
67
  $q.notify({
43
- message: "Failed to copy",
44
- color: "negative"
68
+ message: "\u2705 Secret key copied to clipboard",
69
+ color: "positive",
70
+ timeout: 2e3
45
71
  });
46
- });
72
+ }
47
73
  };
48
74
  </script>
49
75
 
76
+ <style scoped>
77
+ .two-factor-setup{margin:0 auto;max-width:800px}.setup-header{text-align:center}.app-card{transition:all .3s ease}.app-card:hover{box-shadow:0 4px 12px rgba(0,0,0,.1);transform:translateY(-2px)}.qr-card{background:linear-gradient(135deg,#f5f7fa,#c3cfe2);border:2px dashed #1976d2}.secret-input{font-family:Courier New,monospace;font-size:14px}.code-input .q-field__control input{font-family:Courier New,monospace;font-size:24px;font-weight:700;letter-spacing:8px;text-align:center}.verify-section{text-align:center}@media (max-width:600px){.app-card{margin-bottom:1rem}.qr-card img{width:180px!important}.code-input .q-field__control input{font-size:20px;letter-spacing:4px}}.q-btn--loading{pointer-events:none}.q-stepper--vertical .q-stepper__step--done .q-stepper__label{opacity:.7}.q-btn,.q-card{transition:all .2s ease}.q-btn:hover{transform:translateY(-1px)}
78
+ </style>
79
+
50
80
  <template>
51
- <l-form @submit="save" :bordered="false">
52
- <div>
53
- <p>
54
- {{ $t('Now download the app and scan the qrcode. Input the code to the following input and submit') }}
55
- </p>
56
- <p>
57
- For Android user, install
58
- <a type="primary" target="_blank"
59
- href="https://play.google.com/store/apps/details?id=com.azure.authenticator">Microsoft
60
- Authenticator</a>
81
+ <div class="two-factor-setup">
82
+ <!-- Header -->
83
+ <div class="setup-header q-mb-lg">
84
+ <h5 class="q-my-md">{{ $t('Setup Two-Factor Authentication') }}</h5>
85
+ <q-stepper v-model="currentStep" vertical color="primary" animated flat>
86
+ <q-step :name="1" title="Download Authenticator App" icon="download" :done="currentStep > 1">
87
+ <div class="q-mb-md">
88
+ <p class="text-body1 q-mb-md">{{ $t('Choose and download an authenticator app for your device:') }}</p>
89
+
90
+ <!-- App Selection Cards -->
91
+ <div class="row q-gutter-md q-mb-lg">
92
+ <div class="col-12 col-md-5">
93
+ <q-card flat bordered class="app-card">
94
+ <q-card-section class="q-pa-md">
95
+ <div class="text-h6 q-mb-sm">📱 Android</div>
96
+ <div class="q-gutter-sm">
97
+ <q-btn
98
+ outline
99
+ color="primary"
100
+ size="sm"
101
+ target="_blank"
102
+ href="https://play.google.com/store/apps/details?id=com.azure.authenticator"
103
+ label="Microsoft Authenticator"
104
+ class="full-width"
105
+ />
106
+ <q-btn
107
+ outline
108
+ color="primary"
109
+ size="sm"
110
+ target="_blank"
111
+ href="https://play.google.com/store/apps/details?id=com.google.android.apps.authenticator2"
112
+ label="Google Authenticator"
113
+ class="full-width"
114
+ />
115
+ </div>
116
+ </q-card-section>
117
+ </q-card>
118
+ </div>
119
+
120
+ <div class="col-12 col-md-5">
121
+ <q-card flat bordered class="app-card">
122
+ <q-card-section class="q-pa-md">
123
+ <div class="text-h6 q-mb-sm">🍎 iOS</div>
124
+ <div class="q-gutter-sm">
125
+ <q-btn
126
+ outline
127
+ color="primary"
128
+ size="sm"
129
+ target="_blank"
130
+ href="https://apps.apple.com/us/app/microsoft-authenticator/id983156458"
131
+ label="Microsoft Authenticator"
132
+ class="full-width"
133
+ />
134
+ <q-btn
135
+ outline
136
+ color="primary"
137
+ size="sm"
138
+ target="_blank"
139
+ href="https://apps.apple.com/us/app/google-authenticator/id388497605"
140
+ label="Google Authenticator"
141
+ class="full-width"
142
+ />
143
+ </div>
144
+ </q-card-section>
145
+ </q-card>
146
+ </div>
147
+ </div>
61
148
 
62
- or
63
- <a type="primary" target="_blank"
64
- href="https://play.google.com/store/apps/details?id=com.google.android.apps.authenticator2">Google
65
- Authenticator</a>
149
+ <q-btn
150
+ color="primary"
151
+ @click="currentStep = 2"
152
+ :label="$t('I have installed the app')"
153
+ icon-right="arrow_forward"
154
+ />
155
+ </div>
156
+ </q-step>
66
157
 
67
- </p>
158
+ <q-step :name="2" title="Scan QR Code" icon="qr_code" :done="currentStep > 2">
159
+ <div class="text-center q-mb-md">
160
+ <p class="text-body1 q-mb-md">{{ $t('Scan this QR code with your authenticator app:') }}</p>
161
+
162
+ <q-card flat bordered class="qr-card inline-block q-pa-lg">
163
+ <q-img :src="my.my2FA.image" width="200px" class="q-mb-md" />
164
+
165
+ <!-- Manual Entry Option -->
166
+ <q-expansion-item
167
+ icon="key"
168
+ :label="$t('Enter manually instead')"
169
+ class="text-left"
170
+ >
171
+ <div class="q-pa-md bg-grey-1 rounded-borders">
172
+ <div class="text-caption text-grey-7 q-mb-xs">{{ $t('Secret Key:') }}</div>
173
+ <div class="row items-center q-gutter-sm">
174
+ <div class="col">
175
+ <q-input
176
+ :model-value="secretVisible ? (my.my2FA?.secret || '') : '●'.repeat(my.my2FA?.secret?.length || 16)"
177
+ readonly
178
+ dense
179
+ outlined
180
+ class="secret-input"
181
+ />
182
+ </div>
183
+ <q-btn
184
+ flat
185
+ round
186
+ dense
187
+ :icon="secretVisible ? 'visibility_off' : 'visibility'"
188
+ @click="secretVisible = !secretVisible"
189
+ :title="secretVisible ? $t('Hide') : $t('Show')"
190
+ />
191
+ <q-btn
192
+ flat
193
+ round
194
+ dense
195
+ icon="content_copy"
196
+ @click="onCopy"
197
+ :title="$t('Copy to clipboard')"
198
+ />
199
+ </div>
200
+ </div>
201
+ </q-expansion-item>
202
+ </q-card>
68
203
 
69
- <p>
70
- For iOS user, install
71
- <a type="primary" target="_blank"
72
- href="https://apps.apple.com/us/app/microsoft-authenticator/id983156458">Microsoft
73
- Authenticator</a>
74
- or
75
- <a type="primary" target="_blank"
76
- href="https://apps.apple.com/us/app/google-authenticator/id388497605">Google
77
- Authenticator</a>
204
+ <div class="q-mt-md">
205
+ <q-btn
206
+ color="primary"
207
+ @click="currentStep = 3"
208
+ :label="$t('I have scanned the code')"
209
+ icon-right="arrow_forward"
210
+ />
211
+ </div>
212
+ </div>
213
+ </q-step>
78
214
 
79
- </p>
80
- </div>
81
- <q-img :src="my.my2FA.image" width="250px" />
82
- <p>
83
- Secret : <strong>{{ my.my2FA.secret }}</strong>
84
-
85
- <q-btn flat round dense icon="sym_o_content_copy" @click="onCopy" />
86
- </p>
215
+ <q-step :name="3" title="Verify Setup" icon="verified_user">
216
+ <div class="verify-section">
217
+ <p class="text-body1 q-mb-md">
218
+ {{ $t('Enter the 6-digit code from your authenticator app to complete setup:') }}
219
+ </p>
220
+
221
+ <div class="row justify-center q-mb-lg">
222
+ <div class="col-12 col-sm-8 col-md-6">
223
+ <q-input
224
+ v-model="obj.code"
225
+ :label="$t('Verification Code')"
226
+ hint="6-digit code from your authenticator app"
227
+ type="tel"
228
+ inputmode="numeric"
229
+ pattern="[0-9]*"
230
+ outlined
231
+ maxlength="6"
232
+ class="text-center code-input"
233
+ required
234
+ >
235
+ <template v-slot:prepend>
236
+ <q-icon name="security" />
237
+ </template>
238
+ </q-input>
239
+ </div>
240
+ </div>
87
241
 
88
- <l-input v-model="obj.code" label="Code"
89
- hint="Please scan the QR code with your authenticator app, and enter the code generated" required />
90
- </l-form>
242
+ <div class="text-center">
243
+ <q-btn
244
+ @click="save"
245
+ color="positive"
246
+ size="lg"
247
+ :label="$t('Complete Setup')"
248
+ icon="check_circle"
249
+ :loading="loading"
250
+ />
251
+ </div>
252
+ </div>
253
+ </q-step>
254
+ </q-stepper>
255
+ </div>
256
+ </div>
91
257
  </template>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hostlink/nuxt-light",
3
- "version": "1.37.1",
3
+ "version": "1.38.0",
4
4
  "description": "HostLink Nuxt Light Framework",
5
5
  "repository": {
6
6
  "type": "git",