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 +});