diff --git a/components.d.ts b/components.d.ts
index 93fddf8c107e9d0be7d6feec12478b24c252bd54..5135cc66cba7d945284cbe20cf7fcd21764d990e 100644
--- a/components.d.ts
+++ b/components.d.ts
@@ -41,6 +41,7 @@ declare module 'vue' {
APageHeader: typeof import('ant-design-vue/es')['PageHeader']
APopconfirm: typeof import('ant-design-vue/es')['Popconfirm']
AProgress: typeof import('ant-design-vue/es')['Progress']
+ AQrcode: typeof import('ant-design-vue/es')['QRCode']
ARadio: typeof import('ant-design-vue/es')['Radio']
ARadioButton: typeof import('ant-design-vue/es')['RadioButton']
ARadioGroup: typeof import('ant-design-vue/es')['RadioGroup']
@@ -63,6 +64,7 @@ declare module 'vue' {
ATimeline: typeof import('ant-design-vue/es')['Timeline']
ATimelineItem: typeof import('ant-design-vue/es')['TimelineItem']
ATooltip: typeof import('ant-design-vue/es')['Tooltip']
+ ATypographyParagraph: typeof import('ant-design-vue/es')['TypographyParagraph']
AUpload: typeof import('ant-design-vue/es')['Upload']
CaretRightOutlined: typeof import('@ant-design/icons-vue')['CaretRightOutlined']
CarryOutOutlined: typeof import('@ant-design/icons-vue')['CarryOutOutlined']
diff --git a/src/api/baseapi.js b/src/api/baseapi.js
new file mode 100644
index 0000000000000000000000000000000000000000..a5035903294b6e065b85cfd0174aafb9e233c2d7
--- /dev/null
+++ b/src/api/baseapi.js
@@ -0,0 +1,112 @@
+import axios from "axios";
+import { csrfTokenManager } from "@/utils/csrf";
+import { handleApiError, handleHttpError } from "@/common/errorHandler";
+
+const api = axios.create({
+ baseURL: import.meta.env.VITE_API_URL,
+ timeout: 30000,
+ withCredentials: true,
+});
+
+export const isApiSuccess = (payload) => {
+ if (!payload || typeof payload !== "object") {
+ return false;
+ }
+ if (typeof payload.Success === "boolean") {
+ return payload.Success;
+ }
+ if (typeof payload.Code === "number") {
+ return payload.Code === 0;
+ }
+ return false;
+};
+
+export const getApiMessage = (payload) =>
+ payload?.Message || payload?.message || "Request failed";
+
+// 创建一个函数来设置认证头
+const setAuthorizationHeader = (config) => {
+ const token = localStorage.getItem("token");
+ if (token) {
+ config.headers.Authorization = `Bearer ${token}`;
+ }
+ return config;
+};
+
+// 创建一个重试机制
+const retryRequest = async (error) => {
+ const failedRequest = error.config;
+
+ // 如果已经重试过,则不再重试
+ if (failedRequest._retry) {
+ return Promise.reject(error);
+ }
+
+ // 标记这个请求已经重试过
+ failedRequest._retry = true;
+
+ // 重新从 localStorage 获取 token
+ const token = localStorage.getItem("token");
+ if (token) {
+ failedRequest.headers.Authorization = `Bearer ${token}`;
+ return api(failedRequest);
+ }
+
+ return Promise.reject(error);
+};
+
+api.interceptors.request.use(
+ async (config) => {
+ // 设置认证头
+ config = setAuthorizationHeader(config);
+
+ // 设置 CSRF Token
+ const csrfToken = csrfTokenManager.getToken();
+ if (csrfToken) {
+ config.headers["X-CSRF-TOKEN-HEADER"] = csrfToken;
+ }
+
+ return config;
+ },
+ async (error) => {
+ // 检查是否是 token 相关错误
+ if (error.isTokenError) {
+ return retryRequest(error);
+ }
+
+ // 其他错误直接拒绝
+ return Promise.reject(error);
+ },
+);
+
+api.interceptors.response.use(
+ (response) => {
+ if (response?.config?.skipBusinessErrorHandling) {
+ return response;
+ }
+
+ const resData = response.data;
+ if (typeof resData === "string" && resData.includes("Version")) {
+ return response;
+ }
+
+ const hasBusinessFlag =
+ typeof resData?.Success === "boolean" ||
+ typeof resData?.Code === "number";
+
+ if (hasBusinessFlag && !isApiSuccess(resData)) {
+ return handleApiError(resData, response);
+ }
+
+ return response;
+ },
+ (error) => {
+ if (error?.config?.skipHttpErrorHandling) {
+ return Promise.reject(error);
+ }
+
+ return handleHttpError(error);
+ },
+);
+
+export default api;
diff --git a/src/api/basicapi.js b/src/api/basicapi.js
index 09f771d90653fe493496bb15cca6452574edec30..9389e980db0421428af58c7274dd6827daab0a04 100644
--- a/src/api/basicapi.js
+++ b/src/api/basicapi.js
@@ -1,17 +1,51 @@
import api from "../api";
+const LOGIN_ENDPOINTS = {
+ admin: "/Admin/Login",
+ employee: "/Employee/EmployeeLogin",
+};
+
+const TWO_FACTOR_CONTROLLERS = {
+ admin: "Admin",
+ employee: "Employee",
+};
+
+const resolveLoginEndpoint = (loginType = "admin") =>
+ LOGIN_ENDPOINTS[loginType] || LOGIN_ENDPOINTS.admin;
+
+const resolveTwoFactorController = (loginType = "admin") =>
+ TWO_FACTOR_CONTROLLERS[loginType] || TWO_FACTOR_CONTROLLERS.admin;
+
+const toErrorPayload = (error) =>
+ error?.response?.data || {
+ Code: -1,
+ Message: error?.message || "Request failed",
+ Data: null,
+ };
+
+export const isApiSuccess = (payload) => {
+ if (!payload || typeof payload !== "object") return false;
+ if (typeof payload.Success === "boolean") return payload.Success;
+ if (typeof payload.Code === "number") return payload.Code === 0;
+ return false;
+};
+
+export const getApiMessage = (payload) =>
+ payload?.Message || payload?.message || "Request failed";
+
+export const isTwoFactorChallenge = (payload) =>
+ payload?.Code === 1401 && payload?.Data?.RequiresTwoFactor === true;
+
// 登录
-export const signIn = async (data) => {
+export const signIn = async (data, loginType = "admin") => {
try {
- const response = await api.post("/Admin/Login", data);
- const resData = response.data;
- if (resData.Success) {
- return resData;
- } else {
- throw new Error(`Login failed with status code: ${response.status}`);
- }
+ const response = await api.post(resolveLoginEndpoint(loginType), data, {
+ skipBusinessErrorHandling: true,
+ skipHttpErrorHandling: true,
+ });
+ return response.data;
} catch (error) {
- throw error;
+ throw toErrorPayload(error);
}
};
@@ -20,7 +54,7 @@ export const signOut = async (data) => {
try {
const response = await api.post("/Admin/Logout", data);
const resData = response.data;
- if (resData.Success) {
+ if (isApiSuccess(resData)) {
return resData;
} else {
throw new Error(`Logout failed with status code: ${response.status}`);
@@ -36,3 +70,93 @@ export const getServerVersion = async () => {
const resData = response.data;
return resData;
};
+
+export const fetchTwoFactorStatus = async (loginType = "admin") => {
+ try {
+ const controller = resolveTwoFactorController(loginType);
+ const response = await api.get(`/${controller}/GetTwoFactorStatus`, {
+ skipBusinessErrorHandling: true,
+ skipHttpErrorHandling: true,
+ });
+ return response.data;
+ } catch (error) {
+ throw toErrorPayload(error);
+ }
+};
+
+export const generateTwoFactorSetup = async (loginType = "admin") => {
+ try {
+ const controller = resolveTwoFactorController(loginType);
+ const response = await api.post(
+ `/${controller}/GenerateTwoFactorSetup`,
+ {},
+ {
+ skipBusinessErrorHandling: true,
+ skipHttpErrorHandling: true,
+ },
+ );
+ return response.data;
+ } catch (error) {
+ throw toErrorPayload(error);
+ }
+};
+
+export const enableTwoFactor = async (
+ loginType = "admin",
+ verificationCode = "",
+) => {
+ try {
+ const controller = resolveTwoFactorController(loginType);
+ const response = await api.post(
+ `/${controller}/EnableTwoFactor`,
+ { VerificationCode: verificationCode },
+ {
+ skipBusinessErrorHandling: true,
+ skipHttpErrorHandling: true,
+ },
+ );
+ return response.data;
+ } catch (error) {
+ throw toErrorPayload(error);
+ }
+};
+
+export const disableTwoFactor = async (
+ loginType = "admin",
+ verificationCode = "",
+) => {
+ try {
+ const controller = resolveTwoFactorController(loginType);
+ const response = await api.post(
+ `/${controller}/DisableTwoFactor`,
+ { VerificationCode: verificationCode },
+ {
+ skipBusinessErrorHandling: true,
+ skipHttpErrorHandling: true,
+ },
+ );
+ return response.data;
+ } catch (error) {
+ throw toErrorPayload(error);
+ }
+};
+
+export const regenerateTwoFactorRecoveryCodes = async (
+ loginType = "admin",
+ verificationCode = "",
+) => {
+ try {
+ const controller = resolveTwoFactorController(loginType);
+ const response = await api.post(
+ `/${controller}/RegenerateTwoFactorRecoveryCodes`,
+ { VerificationCode: verificationCode },
+ {
+ skipBusinessErrorHandling: true,
+ skipHttpErrorHandling: true,
+ },
+ );
+ return response.data;
+ } catch (error) {
+ throw toErrorPayload(error);
+ }
+};
diff --git a/src/api/index.js b/src/api/index.js
index 23922dd61aa71509376cc02de255c490cb7f1879..a7e79100ac6a85e75de8533742311361296de6ae 100644
--- a/src/api/index.js
+++ b/src/api/index.js
@@ -1,85 +1,4 @@
-import axios from "axios";
-import router from "../router";
-import { csrfTokenManager } from "@/utils/csrf";
-import { handleApiError, handleHttpError } from "@/common/errorHandler";
-
-const api = axios.create({
- baseURL: import.meta.env.VITE_API_URL,
- timeout: 30000,
- withCredentials: true,
-});
-
-// 创建一个函数来设置认证头
-const setAuthorizationHeader = (config) => {
- const token = localStorage.getItem("token");
- if (token) {
- config.headers.Authorization = `Bearer ${token}`;
- }
- return config;
-};
-
-// 创建一个重试机制
-const retryRequest = async (error) => {
- const failedRequest = error.config;
-
- // 如果已经重试过,则不再重试
- if (failedRequest._retry) {
- return Promise.reject(error);
- }
-
- // 标记这个请求已经重试过
- failedRequest._retry = true;
-
- // 重新从 localStorage 获取 token
- const token = localStorage.getItem("token");
- if (token) {
- failedRequest.headers.Authorization = `Bearer ${token}`;
- return api(failedRequest);
- }
-
- return Promise.reject(error);
-};
-
-api.interceptors.request.use(
- async (config) => {
- // 设置认证头
- config = setAuthorizationHeader(config);
-
- // 设置 CSRF Token
- const csrfToken = csrfTokenManager.getToken();
- if (csrfToken) {
- config.headers["X-CSRF-TOKEN-HEADER"] = csrfToken;
- }
-
- return config;
- },
- async (error) => {
- // 检查是否是 token 相关错误
- if (error.isTokenError) {
- return retryRequest(error);
- }
-
- // 其他错误直接拒绝
- return Promise.reject(error);
- },
-);
-
-api.interceptors.response.use(
- (response) => {
- const resData = response.data;
- if (typeof resData === "string" && resData.includes("Version")) {
- return response;
- }
-
- if (!resData.Success) {
- return handleApiError(resData, response);
- }
-
- return response;
- },
- (error) => {
- return handleHttpError(error);
- },
-);
+import api from "./baseapi";
+export * from "./baseapi";
export default api;
diff --git a/src/common/permissioncode.js b/src/common/permissioncode.js
index 205e26527c4c84249e68869e12f43a9da70d3e80..e0cbe850726748aeae3be49209d4c453342b9d36 100644
--- a/src/common/permissioncode.js
+++ b/src/common/permissioncode.js
@@ -69,6 +69,11 @@ export const AdministratorManagementPermissions = {
UPDATE: "administratormanagement.update",
DELETE: "administratormanagement.delete",
ASSIGN_VIEW: "system:user:assign.view",
+ GET_TWO_FACTOR: "system:admin:get2fa",
+ GENERATE_TWO_FACTOR: "system:admin:generate2fa",
+ ENABLE_TWO_FACTOR: "system:admin:enable2fa",
+ DISABLE_TWO_FACTOR: "system:admin:disable2fa",
+ RECOVERY_TWO_FACTOR: "system:admin:recovery2fa",
};
// 角色管理 (Role Management)
@@ -132,6 +137,11 @@ export const StaffManagementPermissions = {
STATUS: "staffmanagement.status",
RESET: "staffmanagement.reset",
ASSIGN_VIEW: "system:user:assign.view",
+ GET_TWO_FACTOR: "staffmanagement.get2fa",
+ GENERATE_TWO_FACTOR: "staffmanagement.generate2fa",
+ ENABLE_TWO_FACTOR: "staffmanagement.enable2fa",
+ DISABLE_TWO_FACTOR: "staffmanagement.disable2fa",
+ RECOVERY_TWO_FACTOR: "staffmanagement.recovery2fa",
};
// ==================== 房间信息管理模块 ====================
diff --git a/src/i18n.js b/src/i18n.js
index cb0915ace2e56d273a034d473dddc1a273e8c53a..63248147e1c9972e2c468d4ba6f96949c4ea7379 100644
--- a/src/i18n.js
+++ b/src/i18n.js
@@ -46,6 +46,50 @@ const messages = {
batchDeleteSuccess: "Batch Delete Success",
resetPassword: "Reset Password",
selectYourLang: "Please choose your language",
+ accountType: "Account Type",
+ pleaseSelectAccountType: "Please select account type",
+ accountTypeAdmin: "Admin",
+ accountTypeEmployee: "Employee",
+ twoFactor: "2FA",
+ twoFactorVerification: "Two-Factor Verification",
+ twoFactorAuthentication: "Two-Factor Authentication",
+ twoFactorPrompt:
+ "Please enter a 6-digit authenticator code or a recovery code.",
+ twoFactorCode: "Verification / Recovery Code",
+ verify: "Verify",
+ enabledAt: "Enabled At",
+ lastVerifiedAt: "Last Verified At",
+ remainingRecoveryCodes: "Remaining Recovery Codes",
+ generateTwoFactorSetup: "Generate Setup",
+ twoFactorSetupSecurityTip:
+ "Do not share the setup key. Save it securely.",
+ enterSixDigitCode: "Enter 6-digit code",
+ enterSixDigitCodeToDisable: "Enter 6-digit code to disable",
+ enterTwoFactorOrRecoveryCode: "Enter 6-digit code or recovery code",
+ enterCodeOrRecoveryCode: "Enter 6-digit code or recovery code",
+ enterCodeOrRecoveryCodeToDisable:
+ "Enter 6-digit code or recovery code to disable",
+ enableTwoFactor: "Enable 2FA",
+ disableTwoFactor: "Disable 2FA",
+ invalidTwoFactorCode: "Please input a valid 6-digit verification code.",
+ invalidCodeOrRecoveryCode:
+ "Please input a valid 6-digit code or recovery code.",
+ twoFactorSetupGenerated: "Two-factor setup generated.",
+ twoFactorEnabledSuccess: "2FA enabled successfully.",
+ twoFactorDisabledSuccess: "2FA disabled successfully.",
+ regenerateRecoveryCodes: "Regenerate Recovery Codes",
+ recoveryCodesOneTimeHint: "Recovery codes are shown once. Save them now.",
+ copyAllRecoveryCodes: "Copy All Recovery Codes",
+ recoveryCodesCopiedSuccess: "Recovery codes copied.",
+ recoveryCodesCopyFailed: "Failed to copy recovery codes.",
+ recoveryCodesRegeneratedSuccess:
+ "Recovery codes regenerated successfully.",
+ lowRecoveryCodesWarning:
+ "Only {count} recovery code(s) left. Regenerate soon.",
+ twoFactorRecoveryCodeSetupTip:
+ "2FA enabled. Save your recovery codes now (shown only once).",
+ recoveryCodeLoginTip:
+ "Recovery code was used for this login. Rebind your authenticator and regenerate recovery codes immediately.",
refreshData: "Refresh Data",
addSuccess: "Add Success",
updateSuccess: "Update Success",
@@ -725,6 +769,44 @@ const messages = {
batchDeleteSuccess: "批量删除成功",
resetPassword: "重置密码",
selectYourLang: "请选择您的语言",
+ accountType: "账号类型",
+ pleaseSelectAccountType: "请选择账号类型",
+ accountTypeAdmin: "管理员",
+ accountTypeEmployee: "员工",
+ twoFactor: "两步验证入口",
+ twoFactorVerification: "两步验证",
+ twoFactorAuthentication: "两步验证设置",
+ twoFactorPrompt: "请输入 6 位验证码或恢复备用码",
+ twoFactorCode: "验证码/备用码",
+ verify: "验证",
+ enabledAt: "启用时间",
+ lastVerifiedAt: "最近验证时间",
+ remainingRecoveryCodes: "剩余备用码数量",
+ generateTwoFactorSetup: "生成绑定信息",
+ twoFactorSetupSecurityTip: "请勿泄露绑定密钥,并妥善保管。",
+ enterSixDigitCode: "请输入 6 位验证码",
+ enterSixDigitCodeToDisable: "请输入 6 位验证码以关闭",
+ enterTwoFactorOrRecoveryCode: "请输入 6 位验证码或恢复备用码",
+ enterCodeOrRecoveryCode: "请输入 6 位验证码或恢复备用码",
+ enterCodeOrRecoveryCodeToDisable: "请输入 6 位验证码或恢复备用码以关闭",
+ enableTwoFactor: "启用 2FA",
+ disableTwoFactor: "关闭 2FA",
+ invalidTwoFactorCode: "请输入有效的 6 位验证码",
+ invalidCodeOrRecoveryCode: "请输入有效的 6 位验证码或恢复备用码",
+ twoFactorSetupGenerated: "已生成 2FA 绑定信息",
+ twoFactorEnabledSuccess: "2FA 启用成功",
+ twoFactorDisabledSuccess: "2FA 关闭成功",
+ regenerateRecoveryCodes: "重置恢复备用码",
+ recoveryCodesOneTimeHint: "备用码明文仅展示一次,请立即保存。",
+ copyAllRecoveryCodes: "复制全部备用码",
+ recoveryCodesCopiedSuccess: "备用码已复制",
+ recoveryCodesCopyFailed: "备用码复制失败,请手动复制",
+ recoveryCodesRegeneratedSuccess: "恢复备用码已重置",
+ lowRecoveryCodesWarning: "剩余备用码仅 {count} 个,建议立即重置。",
+ twoFactorRecoveryCodeSetupTip:
+ "2FA 已启用,请立即保存恢复备用码(仅展示一次)。",
+ recoveryCodeLoginTip:
+ "本次登录使用了恢复备用码,请立即重新绑定验证器并重置备用码。",
refreshData: "刷新数据",
addSuccess: "添加成功",
updateSuccess: "更新成功",
diff --git a/src/layouts/MainLayout.vue b/src/layouts/MainLayout.vue
index 5b8ad2034c594cb51020fc5f16f067646faccfe5..a3bd4aa8d2f35d5d4915b315c830b013b992aace 100644
--- a/src/layouts/MainLayout.vue
+++ b/src/layouts/MainLayout.vue
@@ -24,6 +24,14 @@
{{
username
}}
+
+ {{ $t("message.twoFactor") }}
+
{{
$t("message.logout")
}}
@@ -72,6 +80,145 @@
+
+
+
+
+
+ {{
+ twoFactorStatus?.IsEnabled
+ ? $t("message.enabled")
+ : $t("message.disabled")
+ }}
+
+
+ {{
+ twoFactorStatus?.EnabledAt
+ ? formatDateTime(twoFactorStatus.EnabledAt)
+ : "-"
+ }}
+
+
+ {{
+ twoFactorStatus?.LastVerifiedAt
+ ? formatDateTime(twoFactorStatus.LastVerifiedAt)
+ : "-"
+ }}
+
+
+ {{ twoFactorStatus?.RemainingRecoveryCodes ?? "-" }}
+
+
+
+
+
+
+
+ {{ $t("message.generateTwoFactorSetup") }}
+
+
+
+
+
+
+
+ {{ twoFactorSetupData.ManualEntryKey }}
+
+
+
+ {{ $t("message.enableTwoFactor") }}
+
+
+
+
+
+
+
+ {{ $t("message.regenerateRecoveryCodes") }}
+
+
+
+
+
+ {{ recoveryCodesText }}
+
+
+ {{ $t("message.copyAllRecoveryCodes") }}
+
+
+
+
+
+ {{ $t("message.disableTwoFactor") }}
+
+
+
+
+
© {{ "2024-" + new Date().getFullYear() }} {{ $t("message.org") }}
{{ $t("message.systemName") }} | {{ $t("message.serverVersion") }}:
@@ -93,15 +240,33 @@ import {
} from "vue";
import { useStorage } from "@vueuse/core";
import { useRoute, useRouter } from "vue-router";
-import { showErrorNotification } from "@/utils/index";
+import {
+ formatDateTime,
+ showErrorNotification,
+ showSuccessNotification,
+} from "@/utils/index";
import { fetchMenusTree } from "../api/menuapi";
import { BaseFields } from "@/entities/common.entity";
+import {
+ AdministratorManagementPermissions,
+ StaffManagementPermissions,
+} from "@/common/permissioncode";
import { useI18n } from "vue-i18n";
import RecursiveMenu from "./Menu/RecursiveMenu.vue";
import { emitter } from "@/utils/eventBus";
-import { getServerVersion, signOut } from "../api/basicapi";
+import {
+ disableTwoFactor,
+ enableTwoFactor,
+ fetchTwoFactorStatus,
+ generateTwoFactorSetup,
+ getApiMessage,
+ isApiSuccess,
+ regenerateTwoFactorRecoveryCodes,
+ signOut,
+ getServerVersion,
+} from "../api/basicapi";
const serverVersion = ref("");
@@ -212,6 +377,71 @@ const { locale, t } = useI18n();
const currentLocale = ref(locale.value);
const openKeys = useStorage("menu-open-keys", []);
const currentRouteKey = ref("");
+const loginType = ref(localStorage.getItem("loginType") || "admin");
+const allowedPermissionCodes = ref([]);
+const twoFactorModalOpen = ref(false);
+const twoFactorStatusLoading = ref(false);
+const twoFactorActionLoading = ref(false);
+const twoFactorStatus = ref(null);
+const twoFactorSetupData = ref(null);
+const twoFactorEnableCode = ref("");
+const twoFactorDisableCode = ref("");
+const twoFactorRecoveryCode = ref("");
+const latestRecoveryCodes = ref([]);
+const showRecoveryCodesGuide = ref(false);
+const LOW_RECOVERY_CODES_THRESHOLD = 2;
+const showLowRecoveryCodesWarning = computed(() => {
+ if (!twoFactorStatus.value?.IsEnabled) return false;
+ const remaining = Number(twoFactorStatus.value?.RemainingRecoveryCodes);
+ return (
+ Number.isFinite(remaining) && remaining <= LOW_RECOVERY_CODES_THRESHOLD
+ );
+});
+const recoveryCodesText = computed(() => latestRecoveryCodes.value.join("\n"));
+
+const TWO_FACTOR_PERMISSION_BY_LOGIN_TYPE = {
+ admin: [
+ AdministratorManagementPermissions.GET_TWO_FACTOR,
+ AdministratorManagementPermissions.GENERATE_TWO_FACTOR,
+ AdministratorManagementPermissions.ENABLE_TWO_FACTOR,
+ AdministratorManagementPermissions.DISABLE_TWO_FACTOR,
+ AdministratorManagementPermissions.RECOVERY_TWO_FACTOR,
+ ],
+ employee: [
+ StaffManagementPermissions.GET_TWO_FACTOR,
+ StaffManagementPermissions.GENERATE_TWO_FACTOR,
+ StaffManagementPermissions.ENABLE_TWO_FACTOR,
+ StaffManagementPermissions.DISABLE_TWO_FACTOR,
+ StaffManagementPermissions.RECOVERY_TWO_FACTOR,
+ ],
+};
+
+const parseStoredAllowedPermissions = () => {
+ try {
+ const rawPerms = localStorage.getItem("allowedPerms");
+ const parsedPerms = rawPerms ? JSON.parse(rawPerms) : [];
+ return Array.isArray(parsedPerms) ? parsedPerms : [];
+ } catch (error) {
+ return [];
+ }
+};
+
+const syncAllowedPermissions = () => {
+ allowedPermissionCodes.value = parseStoredAllowedPermissions();
+};
+
+const showTwoFactorEntry = computed(() => {
+ if (!isLoggedIn.value) return false;
+ const requiredPermissions =
+ TWO_FACTOR_PERMISSION_BY_LOGIN_TYPE[loginType.value] || [];
+ if (!Array.isArray(requiredPermissions) || requiredPermissions.length === 0) {
+ return false;
+ }
+ return requiredPermissions.some((code) =>
+ allowedPermissionCodes.value.includes(code),
+ );
+});
+
const selectedKeys = computed(() => {
const normalize = (p) => (p || "").replace(/\/$/, "");
const target = normalize(route.path);
@@ -284,11 +514,12 @@ const refreshMenu = async () => {
const unique = (arr) => Array.from(new Set(arr.filter(Boolean)));
const allowedMenuKeys = unique(keys);
const allowedPaths = unique(paths).map((p) => (p || "").replace(/\/$/, ""));
- const allowedPerms = unique(perms);
+ const allowedPermList = unique(perms);
localStorage.setItem("allowedMenuKeys", JSON.stringify(allowedMenuKeys));
localStorage.setItem("allowedPaths", JSON.stringify(allowedPaths));
- localStorage.setItem("allowedPerms", JSON.stringify(allowedPerms));
+ localStorage.setItem("allowedPerms", JSON.stringify(allowedPermList));
+ allowedPermissionCodes.value = allowedPermList;
// 3) 还原展开状态
openKeys.value = currentOpenKeys;
@@ -318,10 +549,229 @@ const handleLanguageChange = (value) => {
currentLocale.value = value;
};
+const normalizeSixDigits = (value) =>
+ String(value || "")
+ .replace(/\D/g, "")
+ .slice(0, 6);
+
+const normalizeCodeOrRecoveryCode = (value) =>
+ String(value || "")
+ .trim()
+ .toUpperCase()
+ .replace(/\s/g, "")
+ .slice(0, 32);
+
+const isSixDigitCode = (value) => /^\d{6}$/.test(String(value || ""));
+
+const isRecoveryCode = (value) =>
+ /^[A-Z0-9-]{6,32}$/.test(String(value || "")) && !isSixDigitCode(value);
+
+const isValidCodeOrRecoveryCode = (value) =>
+ isSixDigitCode(value) || isRecoveryCode(value);
+
+const updateTwoFactorEnableCode = (value) => {
+ twoFactorEnableCode.value = normalizeSixDigits(value);
+};
+
+const updateTwoFactorDisableCode = (value) => {
+ twoFactorDisableCode.value = normalizeCodeOrRecoveryCode(value);
+};
+
+const updateTwoFactorRecoveryCode = (value) => {
+ twoFactorRecoveryCode.value = normalizeCodeOrRecoveryCode(value);
+};
+
+const copyTextWithExecCommand = (text) => {
+ const textArea = document.createElement("textarea");
+ textArea.value = text;
+ textArea.style.position = "fixed";
+ textArea.style.opacity = "0";
+ document.body.appendChild(textArea);
+ textArea.focus();
+ textArea.select();
+ const copied = document.execCommand("copy");
+ document.body.removeChild(textArea);
+ return copied;
+};
+
+const copyAllRecoveryCodes = async () => {
+ const content = recoveryCodesText.value;
+ if (!content) {
+ return;
+ }
+
+ try {
+ if (navigator?.clipboard?.writeText) {
+ await navigator.clipboard.writeText(content);
+ } else if (!copyTextWithExecCommand(content)) {
+ throw new Error("Copy failed");
+ }
+ showSuccessNotification(t("message.recoveryCodesCopiedSuccess"));
+ } catch (error) {
+ showErrorNotification(t("message.recoveryCodesCopyFailed"));
+ }
+};
+
+const loadTwoFactorStatus = async () => {
+ twoFactorStatusLoading.value = true;
+ try {
+ const response = await fetchTwoFactorStatus(loginType.value);
+ if (isApiSuccess(response)) {
+ twoFactorStatus.value = response.Data || null;
+ if (twoFactorStatus.value?.IsEnabled) {
+ twoFactorSetupData.value = null;
+ twoFactorEnableCode.value = "";
+ } else {
+ twoFactorDisableCode.value = "";
+ twoFactorRecoveryCode.value = "";
+ latestRecoveryCodes.value = [];
+ showRecoveryCodesGuide.value = false;
+ }
+ } else {
+ showErrorNotification(getApiMessage(response));
+ }
+ } catch (error) {
+ showErrorNotification(getApiMessage(error));
+ } finally {
+ twoFactorStatusLoading.value = false;
+ }
+};
+
+const openTwoFactorModal = async () => {
+ if (!showTwoFactorEntry.value) {
+ showErrorNotification(t("message.noPermission"));
+ return;
+ }
+ twoFactorModalOpen.value = true;
+ await loadTwoFactorStatus();
+};
+
+const closeTwoFactorModal = () => {
+ twoFactorModalOpen.value = false;
+ twoFactorSetupData.value = null;
+ twoFactorEnableCode.value = "";
+ twoFactorDisableCode.value = "";
+ twoFactorRecoveryCode.value = "";
+ latestRecoveryCodes.value = [];
+ showRecoveryCodesGuide.value = false;
+};
+
+const handleGenerateTwoFactorSetup = async () => {
+ twoFactorActionLoading.value = true;
+ try {
+ const response = await generateTwoFactorSetup(loginType.value);
+ if (isApiSuccess(response)) {
+ twoFactorSetupData.value = response.Data || null;
+ showSuccessNotification(t("message.twoFactorSetupGenerated"));
+ } else {
+ showErrorNotification(getApiMessage(response));
+ }
+ } catch (error) {
+ showErrorNotification(getApiMessage(error));
+ } finally {
+ twoFactorActionLoading.value = false;
+ }
+};
+
+const handleEnableTwoFactor = async () => {
+ if (twoFactorEnableCode.value.length !== 6) {
+ showErrorNotification(t("message.invalidTwoFactorCode"));
+ return;
+ }
+
+ twoFactorActionLoading.value = true;
+ try {
+ const response = await enableTwoFactor(
+ loginType.value,
+ twoFactorEnableCode.value,
+ );
+ if (isApiSuccess(response)) {
+ const recoveryCodes = Array.isArray(response?.Data?.RecoveryCodes)
+ ? response.Data.RecoveryCodes
+ : [];
+ showSuccessNotification(t("message.twoFactorEnabledSuccess"));
+ twoFactorSetupData.value = null;
+ twoFactorEnableCode.value = "";
+ twoFactorRecoveryCode.value = "";
+ latestRecoveryCodes.value = recoveryCodes;
+ showRecoveryCodesGuide.value = recoveryCodes.length > 0;
+ await loadTwoFactorStatus();
+ } else {
+ showErrorNotification(getApiMessage(response));
+ }
+ } catch (error) {
+ showErrorNotification(getApiMessage(error));
+ } finally {
+ twoFactorActionLoading.value = false;
+ }
+};
+
+const handleRegenerateRecoveryCodes = async () => {
+ if (!isValidCodeOrRecoveryCode(twoFactorRecoveryCode.value)) {
+ showErrorNotification(t("message.invalidCodeOrRecoveryCode"));
+ return;
+ }
+
+ twoFactorActionLoading.value = true;
+ try {
+ const response = await regenerateTwoFactorRecoveryCodes(
+ loginType.value,
+ twoFactorRecoveryCode.value,
+ );
+ if (isApiSuccess(response)) {
+ const recoveryCodes = Array.isArray(response?.Data?.RecoveryCodes)
+ ? response.Data.RecoveryCodes
+ : [];
+ latestRecoveryCodes.value = recoveryCodes;
+ twoFactorRecoveryCode.value = "";
+ showRecoveryCodesGuide.value = recoveryCodes.length > 0;
+ showSuccessNotification(t("message.recoveryCodesRegeneratedSuccess"));
+ await loadTwoFactorStatus();
+ } else {
+ showErrorNotification(getApiMessage(response));
+ }
+ } catch (error) {
+ showErrorNotification(getApiMessage(error));
+ } finally {
+ twoFactorActionLoading.value = false;
+ }
+};
+
+const handleDisableTwoFactor = async () => {
+ if (!isValidCodeOrRecoveryCode(twoFactorDisableCode.value)) {
+ showErrorNotification(t("message.invalidCodeOrRecoveryCode"));
+ return;
+ }
+
+ twoFactorActionLoading.value = true;
+ try {
+ const response = await disableTwoFactor(
+ loginType.value,
+ twoFactorDisableCode.value,
+ );
+ if (isApiSuccess(response)) {
+ showSuccessNotification(t("message.twoFactorDisabledSuccess"));
+ twoFactorDisableCode.value = "";
+ twoFactorRecoveryCode.value = "";
+ latestRecoveryCodes.value = [];
+ showRecoveryCodesGuide.value = false;
+ await loadTwoFactorStatus();
+ } else {
+ showErrorNotification(getApiMessage(response));
+ }
+ } catch (error) {
+ showErrorNotification(getApiMessage(error));
+ } finally {
+ twoFactorActionLoading.value = false;
+ }
+};
+
const checkLoginStatus = () => {
const storedToken = localStorage.getItem("token");
if (storedToken) {
isLoggedIn.value = true;
+ loginType.value = localStorage.getItem("loginType") || "admin";
+ syncAllowedPermissions();
const storedUsername = localStorage.getItem("username");
if (storedUsername) {
username.value = storedUsername;
@@ -330,24 +780,29 @@ const checkLoginStatus = () => {
}
} else {
isLoggedIn.value = false;
+ loginType.value = "admin";
+ allowedPermissionCodes.value = [];
username.value = "";
}
};
const logout = async () => {
var response = await signOut();
- if (!response.Success) {
+ if (!isApiSuccess(response)) {
showErrorNotification(response.Message || t("message.logoutFailed"));
return;
}
localStorage.removeItem("token");
localStorage.removeItem("username");
localStorage.removeItem("account");
+ localStorage.removeItem("loginType");
localStorage.removeItem("allowedPaths");
localStorage.removeItem("allowedMenuKeys");
localStorage.removeItem("allowedPerms");
isLoggedIn.value = false;
+ allowedPermissionCodes.value = [];
username.value = "";
+ closeTwoFactorModal();
router.push("/signin");
};
diff --git a/src/views/SignInView.vue b/src/views/SignInView.vue
index 642a9260b158ada23200e67f9c3c81053d11d7e9..60e780f49867fbc628a779fb82e1af96ac2db3e4 100644
--- a/src/views/SignInView.vue
+++ b/src/views/SignInView.vue
@@ -25,6 +25,22 @@
style="width: 400px; z-index: 2; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1)"
>
+
+
+ {{
+ $t("message.accountTypeAdmin")
+ }}
+ {{
+ $t("message.accountTypeEmployee")
+ }}
+
+
+
+
+
+
+
+
+
+
+
@@ -69,8 +114,18 @@ import bgImage from "@/assets/login_bg.png";
import { ref, reactive, onBeforeMount, computed } from "vue";
import { useRouter } from "vue-router";
-import { showErrorNotification, showSuccessNotification } from "@/utils/index";
-import { signIn } from "../api/basicapi";
+import {
+ showErrorNotification,
+ showInfoNotification,
+ showSuccessNotification,
+ showWarningNotification,
+} from "@/utils/index";
+import {
+ getApiMessage,
+ isApiSuccess,
+ isTwoFactorChallenge,
+ signIn,
+} from "../api/basicapi";
import { useI18n } from "vue-i18n";
const { t, locale } = useI18n();
@@ -87,39 +142,158 @@ const passwordLabel = computed(() => t("message.password"));
const router = useRouter();
const loading = ref(false);
+const twoFactorModalOpen = ref(false);
+const twoFactorLoading = ref(false);
+const twoFactorCode = ref("");
+const pendingLoginContext = ref(null);
+
const form = reactive({
+ LoginType: "admin",
Account: "",
Password: "",
});
-const onFinish = async () => {
- loading.value = true;
- try {
- const response = await signIn(form);
- if (response.Success) {
- const userData = response.Data;
+const normalizeTwoFactorCode = (value) =>
+ String(value || "")
+ .trim()
+ .toUpperCase()
+ .replace(/\s/g, "")
+ .slice(0, 32);
- localStorage.setItem("token", userData.UserToken);
- localStorage.setItem("username", userData.Name);
- localStorage.setItem("account", userData.Number);
+const isSixDigitCode = (value) => /^\d{6}$/.test(String(value || ""));
- router.push("/");
- showSuccessNotification(t("message.welcomeBack"));
- } else {
+const isRecoveryCode = (value) =>
+ /^[A-Z0-9-]{6,32}$/.test(String(value || "")) && !isSixDigitCode(value);
+
+const isValidTwoFactorCode = (value) =>
+ isSixDigitCode(value) || isRecoveryCode(value);
+
+const updateTwoFactorCode = (value) => {
+ twoFactorCode.value = normalizeTwoFactorCode(value);
+};
+
+const buildLoginPayload = (loginType, twoFactorValue = "") => {
+ const account = form.Account.trim();
+ const password = form.Password;
+
+ if (loginType === "employee") {
+ const isEmailAddress = account.includes("@");
+ return {
+ EmployeeId: isEmailAddress ? "" : account,
+ EmailAddress: isEmailAddress ? account : "",
+ Password: password,
+ TwoFactorCode: twoFactorValue,
+ };
+ }
+
+ return {
+ Account: account,
+ Password: password,
+ TwoFactorCode: twoFactorValue,
+ };
+};
+
+const clearTwoFactorChallenge = () => {
+ twoFactorModalOpen.value = false;
+ twoFactorCode.value = "";
+ pendingLoginContext.value = null;
+};
+
+const persistLoginData = async (userData, loginType) => {
+ localStorage.setItem("token", userData.UserToken);
+ localStorage.setItem(
+ "username",
+ userData.Name || userData.UserName || userData.Username || form.Account,
+ );
+ localStorage.setItem(
+ "account",
+ userData.Number || userData.Account || userData.EmployeeId || form.Account,
+ );
+ localStorage.setItem("loginType", loginType);
+
+ clearTwoFactorChallenge();
+ await router.push("/");
+ showSuccessNotification(t("message.welcomeBack"));
+};
+
+const handleLoginResponse = async (result, context, fromTwoFactor = false) => {
+ if (isApiSuccess(result) && result?.Data?.UserToken) {
+ const usedRecoveryCodeLogin = result?.Data?.UsedRecoveryCodeLogin === true;
+ await persistLoginData(result.Data, context.loginType);
+ if (usedRecoveryCodeLogin) {
+ showWarningNotification(t("message.recoveryCodeLoginTip"));
+ }
+ return true;
+ }
+
+ if (isTwoFactorChallenge(result)) {
+ pendingLoginContext.value = context;
+ twoFactorModalOpen.value = true;
+ if (fromTwoFactor) {
showErrorNotification(
- response.message || t("message.checkUsernameAndPassword"),
+ getApiMessage(result) || t("message.invalidCodeOrRecoveryCode"),
);
+ } else {
+ showInfoNotification(t("message.twoFactorPrompt"));
}
+ return false;
+ }
+
+ showErrorNotification(
+ getApiMessage(result) || t("message.checkUsernameAndPassword"),
+ );
+ return false;
+};
+
+const onFinish = async () => {
+ loading.value = true;
+ const context = {
+ loginType: form.LoginType,
+ payload: buildLoginPayload(form.LoginType),
+ };
+
+ try {
+ const response = await signIn(context.payload, context.loginType);
+ await handleLoginResponse(response, context, false);
} catch (error) {
- showErrorNotification(
- error.response?.data?.message || t("message.pleaseTryAgainLater"),
- );
+ await handleLoginResponse(error, context, false);
} finally {
loading.value = false;
}
};
-const onFinishFailed = (errorInfo) => {};
+const submitTwoFactorCode = async () => {
+ if (!pendingLoginContext.value) {
+ twoFactorModalOpen.value = false;
+ return;
+ }
+
+ const verificationCode = normalizeTwoFactorCode(twoFactorCode.value);
+ if (!isValidTwoFactorCode(verificationCode)) {
+ showErrorNotification(t("message.invalidCodeOrRecoveryCode"));
+ return;
+ }
+
+ twoFactorLoading.value = true;
+ try {
+ const { loginType, payload } = pendingLoginContext.value;
+ const response = await signIn(
+ { ...payload, TwoFactorCode: verificationCode },
+ loginType,
+ );
+ await handleLoginResponse(response, pendingLoginContext.value, true);
+ } catch (error) {
+ await handleLoginResponse(error, pendingLoginContext.value, true);
+ } finally {
+ twoFactorLoading.value = false;
+ }
+};
+
+const cancelTwoFactorChallenge = () => {
+ clearTwoFactorChallenge();
+};
+
+const onFinishFailed = () => {};
onBeforeMount(() => {
currentLocale.value = locale.value;
diff --git a/vite.config.js b/vite.config.js
index 1208d8d4ab4ec162a43741d9134799035e9ee4d2..66dfa60b09c987b7ccafecb1a75870102da61759 100644
--- a/vite.config.js
+++ b/vite.config.js
@@ -113,4 +113,4 @@ export default defineConfig(({ mode }) => {
},
},
};
-});
\ No newline at end of file
+});