From f0bfe7ecadcdb111ef5792592df78b7150fc8a46 Mon Sep 17 00:00:00 2001 From: limbo Date: Mon, 20 Jan 2025 11:03:28 +0800 Subject: [PATCH] feat(add enterprise real name auth) (#5347) * feat(add enterprise real name auth * chore * ok * chore * chore * chore --- frontend/desktop/.gitignore | 1 + frontend/desktop/package.json | 2 - .../desktop/public/locales/en/common.json | 60 +- .../desktop/public/locales/zh/common.json | 46 +- frontend/desktop/src/api/auth.ts | 48 +- .../account/AccountCenter/index.tsx | 57 +- .../src/components/account/RealNameModal.tsx | 1248 +++++++++++------ .../src/components/desktop_content/index.tsx | 2 +- .../components/signin/auth/usePassword.tsx | 2 - .../src/components/signin/auth/useWechat.tsx | 2 - .../pages/api/account/enterpriseRealName.ts | 320 +++++ .../api/account/enterpriseRealNameAuth.ts | 245 ---- .../api/account/enterpriseRealNameVerify.ts | 95 ++ .../api/account/faceIdRealNameAuthCallback.ts | 22 +- .../api/account/generateRealNameQRcodeUri.ts | 18 +- frontend/desktop/src/pages/api/auth/info.ts | 14 +- .../src/pages/api/platform/getCommonConfig.ts | 3 +- .../src/types/response/enterpriseRealName.ts | 29 + frontend/desktop/src/types/session.ts | 1 - frontend/desktop/src/types/system.ts | 2 - frontend/desktop/src/types/token.ts | 1 + frontend/desktop/src/utils/sessionConfig.ts | 1 - frontend/pnpm-lock.yaml | 6 - 23 files changed, 1463 insertions(+), 762 deletions(-) create mode 100644 frontend/desktop/src/pages/api/account/enterpriseRealName.ts delete mode 100644 frontend/desktop/src/pages/api/account/enterpriseRealNameAuth.ts create mode 100644 frontend/desktop/src/pages/api/account/enterpriseRealNameVerify.ts create mode 100644 frontend/desktop/src/types/response/enterpriseRealName.ts diff --git a/frontend/desktop/.gitignore b/frontend/desktop/.gitignore index 3d4d46cf7..329a68a1d 100644 --- a/frontend/desktop/.gitignore +++ b/frontend/desktop/.gitignore @@ -42,5 +42,6 @@ yalc.lock config.yaml .env +.env.local data/config.local.yaml #/prisma/region/generated/ diff --git a/frontend/desktop/package.json b/frontend/desktop/package.json index 7a7be9f5a..ca57c7a6f 100644 --- a/frontend/desktop/package.json +++ b/frontend/desktop/package.json @@ -42,7 +42,6 @@ "decimal.js": "^10.4.3", "eslint": "8.38.0", "eslint-config-next": "13.3.0", - "formidable": "^3.5.1", "framer-motion": "^10.16.4", "i18next": "^23.11.5", "immer": "^10.0.2", @@ -81,7 +80,6 @@ "devDependencies": { "@testing-library/jest-dom": "^6.1.3", "@testing-library/react": "^14.0.0", - "@types/formidable": "^3.4.5", "@types/jest": "^29.5.10", "@types/js-cookie": "^3.0.4", "@types/js-yaml": "^4.0.6", diff --git a/frontend/desktop/public/locales/en/common.json b/frontend/desktop/public/locales/en/common.json index 49352373b..3dec81410 100644 --- a/frontend/desktop/public/locales/en/common.json +++ b/frontend/desktop/public/locales/en/common.json @@ -3,6 +3,9 @@ "accept": "Accept", "accept_invitation": "Accept Invitation", "access": "Access", + "account_bank_required": "Please enter the correct bank name of the enterprise", + "account_number": "Bank Account", + "account_number_required": "Please enter the correct recipient bank account of the enterprise", "account_settings": "Account Settings", "added": "Added", "agree_policy": "I have read and agree to the", @@ -19,6 +22,7 @@ "attachment": "appendix", "avatar": "Avatar", "balance": "Balance", + "bank_name": "Bank", "bind": "Link", "bind_success": "Binding successful", "bonus": "Bonus", @@ -39,6 +43,9 @@ "confirm": "Confirm", "confirm_again": "confirm again", "confirmnewpassword": "Confirm New Password", + "contact_info": "Phone Number", + "contact_info_must_be_numeric": "It must be a numeric string", + "contact_info_required": "Please enter the correct contact information", "core": "Core", "cost_center": "Cost Center", "create_team": "Create Workspace", @@ -67,9 +74,22 @@ "emailchangesuccess": "Email modified successfully", "enter": "Enter", "enter_confirm": "Please enter {{value}} to confirm", + "enterpriseKey": "Enterprise Name (Bank Account Name)", + "enterpriseKeyPlaceholder": "Enter the name of your enterprise", + "enterprise_auth_tips": "We will deposit a small amount into your enterprise bank account.\nConfirm the amount to complete verification. ", + "enterprise_key": "Unified Social Credit Code", + "enterprise_key_required": "Please enter the correct Unified Social Credit Code (USCC) of the enterprise", + "enterprise_keyname": "Enterprise Name (Bank Account Name) ", + "enterprise_keyname_placeholder": "Enter the name of your enterprise", "enterprise_name": "Company name", - "enterprise_name_required": "Please enter the correct company name", - "enterprise_verification": "Enterprise real name", + "enterprise_name_required": "Please enter the correct enterprise name", + "enterprise_realname_cancel_failed": "Cancellation failed.", + "enterprise_realname_cancel_success": "Cancellation successful.", + "enterprise_realname_payment_failed": "Verify that the payment amount failed to be transferred.", + "enterprise_realname_payment_success": "Verify that the amount has been transferred to your company's bank account.", + "enterprise_realname_verify_failed": "Enterprise real-name verification failed.", + "enterprise_realname_verify_success": "Enterprise real-name verification successful.", + "enterprise_verification": "Enterprise", "expected_to_use_next_month": "Usage for the next 30 days", "expected_used": "Estimated Runaway", "face_recognition_failed": "Personal real name failed", @@ -83,6 +103,7 @@ "generate_invitation_link": "Generate invitation link", "get_code": "verification", "get_code_failed": "Get code failed", + "get_verification_amount": "Request", "gift_amount": "Reward {{amount}} balance.", "github": "Github", "google": "Google", @@ -109,6 +130,7 @@ "invalid_phone_number": "Invalid phone number", "invalid_user_id": "Invalid User ID", "invalid_username_or_password": "Invalid username or password", + "invalid_verification_amount": "The verification amount format is invalid", "invalid_verification_code": "Invalid verification code", "invitation_reminder": "Invitation reminder", "invite_member": "Invite Member", @@ -118,8 +140,10 @@ "laf_on_sealos": "Laf on Sealos", "language": "Language", "launch_various_third-party_applications_with_one_click": "Launch various third-party applications with one click", + "legal_person": "Legal Representative", "license_buy": "License Buy", "link": "link", + "link_to_workorder": "Submit a Ticket", "loading": "Loading", "log_in": "Log In", "log_out": "Log Out", @@ -149,7 +173,7 @@ "next_time": "Next", "nickname": "Nickname", "no_apps_found": "No Apps Found", - "no_realname_auth": "NO REALNAME AUTH", + "no_realname_auth": "Identity Not Verified", "notification": "Notification", "noworkspacecreated": "You haven't created a workspace yet", "official_account_login": "Official account login", @@ -168,17 +192,18 @@ "payment_result": "Payment Result", "payment_status": "Payment Status", "payment_successful": "Payment Successful", - "personal_verification": "Personal real name", + "personal_verification": "Individual", "phone": "Phone", "phone_number_tips": "Phone Number", "phonechangesuccess": "Mobile phone number modified successfully", "please_enter": "Please enter", + "please_enter_account_number": "Enter the bank account number", + "please_enter_bank_name": "Enter a bank name", + "please_enter_contact_info": "Enter the phone number", + "please_enter_enterprise_key": "Enter the Unified Social Credit Code", + "please_enter_legal_person": "Enter the name of the legal representative", "please_enter_username": "Please enter your username", - "please_enter_your_enterprise_name": "Please enter your business name", - "please_fill_all_fields": "File cannot be empty", - "please_read_and_agree_to_the_agreement": "Please read and agree to the agreement", - "please_upload_the_business_license": "Please upload a photo of your business license", - "please_upload_the_supporting_materials": "Please sign and stamp the downloaded attachment and upload a photo", + "please_enter_verification_amount": "Enter the verification amount (in cents).", "privacy_policy": "Privacy Policy", "private_team_id_of_user": "User's ID", "purchase_history": "Purchase History", @@ -190,12 +215,11 @@ "read_all": "Read All", "read_and_agree": "Please read and agree to the agreement below", "realNameVerification": "Real Name Verification", - "realName_verification": "Real Name Verification", + "realName_verification": "Identity Verification", "realname_auth_now": "Click to verify your name", "realname_auth_reminder": "Real-name authentication reminder", "realname_auth_reminder_desc": "Real-name verification is required for regions in China. Without real-name verification, top-up will be restricted. Successful real-name verification will be rewarded with a {{reward}} Sealos balance.", - "realname_auth_tips_a": "1. Please ensure that the name, ID number and mobile phone number filled in are consistent.", - "realname_auth_tips_b": "2. The number segments provided by some virtual operators may not pass verification. Please apply for a work order and pass manual verification.", + "realname_auth_reminder_desc_no_reward": "Real-name verification is required for regions in China. Without real-name verification, top-up will be restricted.", "realname_info": "RealName", "receive_tips": "{{managerName}} invite you join in {{teamName}} as {{role}}", "recharge_amount": "Recharge Amount", @@ -208,6 +232,7 @@ "remain_other_region_resource_tips": "There are still associated resources that have not been deleted in your account. To help you successfully complete the account cancellation process, please clean up all region resources to ensure that nothing is missing.", "remain_template_tips": "There are still undeleted template resources in your account. To help you smoothly complete the account cancellation process, please manually delete all template resources to avoid data loss", "remain_workspace_tips": "There are still undeleted associated resources in your account. To help you successfully complete the account cancellation process, please clean up or transfer your workspace to avoid data loss.", + "remaining_attempts": "{{count}} times left", "remaining_time": "Remaining Time: ", "remove": "Remove", "remove_member_tips": "Determine that you want to remove the member?", @@ -229,22 +254,23 @@ "status": "Status", "storage": "Storage", "submit_error": "Submit Error", - "supporting_materials": "Proof material", + "submit_verification": "Submit ", "switching_disc": "Switching Disc", "team": "Workspace", "terminal": "Terminal", "the_invited_user_must_be_others": "The invited user must be others", "toggle_app_bar": "Toggle App Bar", "total_amount": "Total Amount", + "transAmt_not_match": "The verification amount does not match. Please note that the verification amount is in cents. For example, if the received amount is 0.23 yuan, enter 23.", "unbind": "Unbind", "unbind_success": "Unbinding successfully", "unbound": "Not Linked", "under_active_development": "Under active development 🚧", "unread": "Unread", - "upload_success": "Upload successful", "used_last_month": "Usage for the last 30 days", "used_resources": "Resources Used", "user_name": "User Name", + "user_name_required": "Please enter the correct name of the enterprise's legal representative", "username": "Username", "username_tips": "Username must be 3-16 characters, including letters, numbers", "usertask": { @@ -255,6 +281,10 @@ "task_launchpad_desc": "Create a container cluster with one click, automatically deploy container applications, and provide intranet/extranet access addresses", "task_launchpad_title": "Deploy App" }, + "verification_amount": "Verification Amount", + "verification_amount_required": "The verification amount cannot be empty", + "verification_amount_tips": "Shanghai UnionPay will deposit a random amount into your bank account for\nverification, typically arriving in real-time. If not received in 3 business day, please ", + "verification_amount_tips2": "Please confirm the deposited verification amount with your finance team. It usually arrives in real-time.", "verification_code_login": "with Phone", "verify_code_tips": "6-digit Verification Code", "verify_password": "Verify password", @@ -272,4 +302,4 @@ "you_can_view_fees_through_the_fee_center": "You can view fees through the fee center", "you_have_not_purchased_the_license": "You have not purchased the License", "yuan": "Yuan" -} \ No newline at end of file +} diff --git a/frontend/desktop/public/locales/zh/common.json b/frontend/desktop/public/locales/zh/common.json index a21880bd6..1e56f9d94 100644 --- a/frontend/desktop/public/locales/zh/common.json +++ b/frontend/desktop/public/locales/zh/common.json @@ -3,6 +3,9 @@ "accept": "接受", "accept_invitation": "接受邀请", "access": "权限", + "account_bank_required": "请输入正确的企业开户行", + "account_number": "银行账号", + "account_number_required": "请输入正确的企业开户行收款银行账号", "account_settings": "账户设置", "added": "已加入", "agree_policy": "我已阅读并同意", @@ -18,6 +21,7 @@ "attachment": "附件", "avatar": "头像", "balance": "余额", + "bank_name": "开户银行", "bind": "绑定", "bind_success": "绑定成功", "bonus": "赠", @@ -38,6 +42,9 @@ "confirm": "确认", "confirm_again": "再次确认", "confirmnewpassword": "确认新密码", + "contact_info": "联系方式 (手机号码)", + "contact_info_must_be_numeric": "必须是数字字符串", + "contact_info_required": "请输入正确的联系方式", "core": "核", "create_team": "创建工作空间", "created_time": "创建时间", @@ -64,8 +71,19 @@ "emailchangesuccess": "电子邮箱修改成功", "enter": "输入", "enter_confirm": "请输入 {{value}} 确认", + "enterprise_auth_tips": "我们会向你提交的对公账户进行小额打款, 请输入收到的金额完成验证。", + "enterprise_key": "统一社会信用代码", + "enterprise_key_required": "请输入正确的企业统一社会信用代码", + "enterprise_keyname": "企业名称 (需与银行开户名一致)", + "enterprise_keyname_placeholder": "请输入企业名称 (需与银行开户名一致)", "enterprise_name": "企业名称", "enterprise_name_required": "请输入正确的企业名字", + "enterprise_realname_cancel_failed": "取消失败", + "enterprise_realname_cancel_success": "取消成功", + "enterprise_realname_payment_failed": "验证金额打款失败", + "enterprise_realname_payment_success": "验证金额已经打款到您的企业开户银行", + "enterprise_realname_verify_failed": "企业实名失败", + "enterprise_realname_verify_success": "企业实名成功", "enterprise_verification": "企业实名", "expected_to_use_next_month": "未来30天预计使用", "expected_used": "预计还能使用", @@ -80,6 +98,7 @@ "generate_invitation_link": "生成邀请链接", "get_code": "获取验证码", "get_code_failed": "获取验证码失败", + "get_verification_amount": "获取验证金额", "gift_amount": "赠送 {{amount}} 余额.", "github": "Github", "google": "Google", @@ -106,6 +125,7 @@ "invalid_phone_number": "无效的手机号", "invalid_user_id": "用户的 ID 不合法", "invalid_username_or_password": "用户名或密码错误", + "invalid_verification_amount": "验证金额格式不合法", "invalid_verification_code": "无效的验证码", "invitation_reminder": "受邀提醒", "invite_member": "邀请成员", @@ -114,8 +134,10 @@ "jump_over": "跳过", "language": "语言", "launch_various_third-party_applications_with_one_click": "一键启动各种第三方应用", + "legal_person": "法定代表人", "license_buy": "License 购买", "link": "链接", + "link_to_workorder": "提交工单", "loading": "加载中", "log_in": "登录", "log_out": "登出", @@ -169,12 +191,13 @@ "phone_number_tips": "手机号", "phonechangesuccess": "手机号修改成功", "please_enter": "请输入", + "please_enter_account_number": "请输入银行账号", + "please_enter_bank_name": "请输入开户行名", + "please_enter_contact_info": "请输入联系方式", + "please_enter_enterprise_key": "请输入统一社会信用代码", + "please_enter_legal_person": "请输入法人姓名", "please_enter_username": "请输入您的用户名", - "please_enter_your_enterprise_name": "请输入您的企业名称", - "please_fill_all_fields": "文件不能为空", - "please_read_and_agree_to_the_agreement": "请阅读并同意协议", - "please_upload_the_business_license": "请上传企业的营业执照照片", - "please_upload_the_supporting_materials": "请在下载的附件上签名并盖好公章,上传照片", + "please_enter_verification_amount": "请输入验证金额(单位分)", "privacy_policy": "隐私政策", "private_team_id_of_user": "用户ID", "purchase_history": "购买记录", @@ -190,8 +213,7 @@ "realname_auth_now": "点击进行实名", "realname_auth_reminder": "实名认证提醒", "realname_auth_reminder_desc": "国内可用区需要实名认证,未实名认证将会被限制充值,实名认证成功奖励 Sealos 余额 {{reward}} 元。", - "realname_auth_tips_a": "1、请确保所填写的姓名、身份证号码和手机号码信息一致。", - "realname_auth_tips_b": "2、部分虚拟运营商提供的号段可能无法验证通过,请申请工单通过人工协助认证。", + "realname_auth_reminder_desc_no_reward": "国内可用区需要实名认证,未实名认证将会被限制充值。", "realname_info": "实名信息", "receive_tips": "{{managerName}} 邀请你到 {{teamName}} 成为 {{role}}", "recharge_amount": "充值金额", @@ -204,6 +226,7 @@ "remain_other_region_resource_tips": "您好,您的账户中仍有未删除的关联资源,为了帮助您顺利完成账户注销流程,请您清理所有可用区资源,确保无遗漏。", "remain_template_tips": "您的账户中仍有未删除的模板资源,为了帮助您顺利完成账户注销流程,请您手动删除所有模板资源,以避免数据丢失", "remain_workspace_tips": "您的账户中仍有未删除的关联资源,为了帮助您顺利完成账户注销流程,请您清理或转移您的工作空间,以避免数据丢失", + "remaining_attempts": "剩余 {{count}} 次", "remaining_time": "剩余激活时间: ", "remove": "移除", "remove_member_tips": "确认要移除该成员?", @@ -224,20 +247,21 @@ "status": "状态", "storage": "存储", "submit_error": "提交错误", - "supporting_materials": "证明材料", + "submit_verification": "提交认证 ", "switching_disc": "切换圆盘", "team": "工作空间", "the_invited_user_must_be_others": "只能邀请其他人", "toggle_app_bar": "切换应用栏", + "transAmt_not_match": "验证金额不匹配,注意验证金额单位是分,例如收到的打款金额为 0.23 元 则输入23。", "unbind": "解绑", "unbind_success": "解绑成功", "unbound": "未绑定", "under_active_development": "正在积极开发中 🚧", "unread": "未读", - "upload_success": "上传成功", "used_last_month": "过去30天已使用", "used_resources": "已用资源", "user_name": "用户名", + "user_name_required": "请输入正确的企业法人姓名", "username": "用户名", "username_tips": "用户名为3-16位的英文或数字的字符", "usertask": { @@ -248,6 +272,10 @@ "task_launchpad_desc": "一键创建容器集群,自动化 部署容器应用,并提供内 网/外网访问地址", "task_launchpad_title": "部署应用" }, + "verification_amount": "验证金额", + "verification_amount_required": "验证金额不能为空", + "verification_amount_tips": "验证金额将由上海银联打入你提交的对公账号, 金额随机, 一般实时到账。若三个工作日内未收到, 请", + "verification_amount_tips2": "请联系公司财务确认企业银行账户收到的验证金额, 一般实时到账, 长期有效。", "verification_code_login": "手机号登录", "verify_code_tips": "6位验证码", "verify_password": "确认密码", diff --git a/frontend/desktop/src/api/auth.ts b/frontend/desktop/src/api/auth.ts index 33ee7e991..d09ca3c84 100644 --- a/frontend/desktop/src/api/auth.ts +++ b/frontend/desktop/src/api/auth.ts @@ -6,6 +6,7 @@ import { ApiResp, Region } from '@/types'; import { BIND_STATUS } from '@/types/response/bind'; import { RESOURCE_STATUS } from '@/types/response/checkResource'; import { DELETE_USER_STATUS } from '@/types/response/deleteUser'; +import { EnterpriseAuthInfo, PAYMENTSTATUS } from '@/types/response/enterpriseRealName'; import { USER_MERGE_STATUS } from '@/types/response/merge'; import { UNBIND_STATUS } from '@/types/response/unbind'; import { SemData } from '@/types/sem'; @@ -58,7 +59,6 @@ export const _UserInfo = (request: AxiosInstance) => () => ApiResp<{ info: { realName?: string; - enterpriseVerificationStatus?: string; enterpriseRealName?: string; userRestrictedLevel?: number; uid: string; @@ -172,15 +172,37 @@ export const _getFaceAuthStatusRequest = (request: AxiosInstance) => (data: { bi data ); -export const _enterpriseRealNameAuthRequest = (request: AxiosInstance) => (data: FormData) => { - return request.post>( - '/api/account/enterpriseRealNameAuth', - data, - { - headers: { - 'Content-Type': 'multipart/form-data' - } - } +export const _enterpriseRealNameAuthPaymentRequest = + (request: AxiosInstance) => + (data: { + key: string; + accountBank: string; + accountNo: string; + keyName: string; + usrName: string; + contactInfo: string; + }) => { + return request.post>( + '/api/account/enterpriseRealName', + data + ); + }; + +export const _enterpriseRealNameAuthVerifyRequest = + (request: AxiosInstance) => (data: { transAmt: string }) => { + return request.post< + typeof data, + ApiResp<{ authState: 'success' | 'failed'; enterpriseRealName: string }> + >('/api/account/enterpriseRealNameVerify', data); + }; + +export const _enterpriseRealNameAuthInfoRequest = (request: AxiosInstance) => () => { + return request.get>('/api/account/enterpriseRealName'); +}; + +export const _enterpriseRealNameAuthCancelRequest = (request: AxiosInstance) => () => { + return request.patch>( + '/api/account/enterpriseRealName' ); }; @@ -211,7 +233,11 @@ export const deleteUserRequest = _deleteUser(request); export const checkRemainResource = _checkRemainResource(request); export const forceDeleteUser = _forceDeleteUser(request); -export const enterpriseRealNameAuthRequest = _enterpriseRealNameAuthRequest(request); +export const enterpriseRealNameAuthPaymentRequest = _enterpriseRealNameAuthPaymentRequest(request); +export const enterpriseRealNameAuthVerifyRequest = _enterpriseRealNameAuthVerifyRequest(request); +export const enterpriseRealNameAuthInfoRequest = _enterpriseRealNameAuthInfoRequest(request); +export const enterpriseRealNameAuthCancelRequest = _enterpriseRealNameAuthCancelRequest(request); + export const faceAuthGenerateQRcodeUriRequest = _faceAuthGenerateQRcodeUriRequest(request); export const getFaceAuthStatusRequest = _getFaceAuthStatusRequest(request); diff --git a/frontend/desktop/src/components/account/AccountCenter/index.tsx b/frontend/desktop/src/components/account/AccountCenter/index.tsx index 8d70dd22d..3d45b3d3d 100644 --- a/frontend/desktop/src/components/account/AccountCenter/index.tsx +++ b/frontend/desktop/src/components/account/AccountCenter/index.tsx @@ -222,14 +222,34 @@ export default function Index(props: Omit) { {t('common:realname_info')}} RightElement={ - infoData.data.enterpriseVerificationStatus === 'Success' || - infoData.data.realName ? ( - + infoData.data.enterpriseRealName || infoData.data.realName ? ( + + + + {infoData?.data.enterpriseRealName || infoData?.data.realName} @@ -240,8 +260,37 @@ export default function Index(props: Omit) { _active={{ transform: 'scale(0.95)' }} colorScheme="red" onClick={() => setPageState(PageState.REALNAME_AUTH)} + display="flex" + padding="4px 4px 4px 8px" + justifyContent="center" + alignItems="center" + gap="2px" + borderRadius="6px" + bg="var(--Red-50, #FEF3F2)" + color="var(--Red-500, #F04438)" + fontFamily="PingFang SC" + fontSize="14px" + fontStyle="normal" + fontWeight="500" + lineHeight="20px" + letterSpacing="0.1px" + textTransform="none" > - {t('common:no_realname_auth')} + {t('common:no_realname_auth')} + + + ) } diff --git a/frontend/desktop/src/components/account/RealNameModal.tsx b/frontend/desktop/src/components/account/RealNameModal.tsx index dfc40a332..8c01662c9 100644 --- a/frontend/desktop/src/components/account/RealNameModal.tsx +++ b/frontend/desktop/src/components/account/RealNameModal.tsx @@ -20,26 +20,20 @@ import { Spinner, Link, FormErrorMessage, - HStack, - TabIndicator, FlexProps } from '@chakra-ui/react'; import { CloseIcon, useMessage, WarningIcon } from '@sealos/ui'; import { useTranslation } from 'next-i18next'; -import React, { - forwardRef, - ReactElement, - useCallback, - useEffect, - useImperativeHandle, - useState -} from 'react'; +import React, { forwardRef, useCallback, useEffect, useImperativeHandle, useState } from 'react'; import { Tabs, TabList, TabPanels, Tab, TabPanel } from '@chakra-ui/react'; import { useForm } from 'react-hook-form'; import { z } from 'zod'; import { zodResolver } from '@hookform/resolvers/zod'; import { - enterpriseRealNameAuthRequest, + enterpriseRealNameAuthCancelRequest, + enterpriseRealNameAuthInfoRequest, + enterpriseRealNameAuthPaymentRequest, + enterpriseRealNameAuthVerifyRequest, faceAuthGenerateQRcodeUriRequest, getFaceAuthStatusRequest } from '@/api/auth'; @@ -47,9 +41,8 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; import useSessionStore from '@/stores/session'; import { useQuery } from '@tanstack/react-query'; import QRCode from 'qrcode.react'; -import { useDropzone } from 'react-dropzone'; import { useConfigStore } from '@/stores/config'; -import { DeleteIcon, PictureIcon, UploadIcon, AttachmentIcon } from '../icons'; +import { PAYMENTSTATUS } from '@/types/response/enterpriseRealName'; export function useRealNameAuthNotification(props?: UseToastOptions) { const { t } = useTranslation(); @@ -81,7 +74,6 @@ export function useRealNameAuthNotification(props?: UseToastOptions) { color="yellow.600" fontSize="16px" fontWeight={500} - fontFamily="PingFang SC" lineHeight="24px" letterSpacing="0.15px" fontStyle="normal" @@ -111,21 +103,21 @@ export function useRealNameAuthNotification(props?: UseToastOptions) { - {t('common:realname_auth_reminder_desc', { - reward: realNameReward - })} + {realNameReward?.toString() === '0' + ? t('common:realname_auth_reminder_desc_no_reward') + : t('common:realname_auth_reminder_desc', { + reward: realNameReward + })} {t('common:realName_verification')} - + - + {t('common:personal_verification')} - {/* {t('common:enterprise_verification')} - */} + - - + { @@ -242,7 +273,7 @@ const RealNameModal = forwardRef< }} /> - {/* + { onClose(); @@ -251,7 +282,7 @@ const RealNameModal = forwardRef< } }} /> - */} + @@ -269,36 +300,72 @@ export function RealNameAuthForm( const { t } = useTranslation(); return ( - + {t('common:personal_verification')} - {/* {t('common:enterprise_verification')} - */} + - - + { @@ -308,7 +375,7 @@ export function RealNameAuthForm( }} /> - {/* + { if (props.onFormSuccess && typeof props.onFormSuccess === 'function') { @@ -316,7 +383,7 @@ export function RealNameAuthForm( } }} /> - */} + ); @@ -424,7 +491,7 @@ export function FaceIdRealNameAuthORcode( if (error) { return ( - + {t('common:failed_to_get_qr_code')} @@ -435,7 +502,7 @@ export function FaceIdRealNameAuthORcode( if (isLoading) { return ( -
+
{t('common:loading')}
@@ -443,13 +510,12 @@ export function FaceIdRealNameAuthORcode( } return ( - + {data?.data?.url && ( <>
- {isPolling && ( - - {t('common:waiting_for_face_recognition')} - - )}
)} @@ -483,420 +535,746 @@ export function FaceIdRealNameAuthORcode( ); } -function FileUploadBox({ - onDrop, - file, - removeFile, - label, - description, - isAttachment -}: { - onDrop: (acceptedFiles: File[]) => void; - file: File | null; - removeFile: () => void; - label: string; - description: string; - isAttachment?: boolean; -}) { - const { commonConfig } = useConfigStore((s) => s); - const enterpriseSupportingMaterialsUri = commonConfig?.enterpriseSupportingMaterials; - const { getRootProps, getInputProps } = useDropzone({ - onDrop, - maxFiles: 1, - accept: { - 'image/*': ['.png', '.jpg', '.jpeg'], - 'application/pdf': ['.pdf'] - } - }); - const { t } = useTranslation(); - - return ( - - - {label} - - - - {description} - - - - - - - {t('common:click_to_upload_file')} - - - {' '} - - - {file ? ( - - - - - {file.name} - - - - - ) : null} - - {isAttachment && ( - - - - - {t('common:attachment')} - - - - )} - - - - ); -} - function EnterpriseVerification( props: FlexProps & { onFormSuccess?: () => void; } ) { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); const { message } = useMessage(); - const { session } = useSessionStore((s) => s); const { setSessionProp } = useSessionStore(); - const queryClient = useQueryClient(); + const domain = useConfigStore((state) => state.cloudConfig?.domain); const schema = z.object({ - enterpriseName: z + key: z.string().min(1, { message: t('common:enterprise_key_required') }), + accountBank: z.string().min(1, { message: t('common:account_bank_required') }), + accountNo: z.string().min(1, { message: t('common:account_number_required') }), + keyName: z.string().min(1, { message: t('common:enterprise_name_required') }), + usrName: z.string().min(1, { message: t('common:user_name_required') }), + contactInfo: z .string() - .min(1, { message: t('common:enterprise_name_required') }) - .max(50, { message: t('common:enterprise_name_required') }), - enterpriseQualification: z.instanceof(File).nullable(), - supportingMaterials: z.instanceof(File).nullable() + .min(1, { message: t('common:contact_info_required') }) + .regex(/^\d+$/, { message: t('common:contact_info_must_be_numeric') }) }); + const verificationSchema = z.object({ + transAmt: z + .string() + .refine((val) => /^\d{1,3}$/.test(val), { message: t('common:invalid_verification_amount') }) + }); + + const { data: enterpriseRealNameAuthInfo, isLoading: enterpriseRealNameAuthInfoLoading } = + useQuery(['enterpriseRealNameAuthInfo'], enterpriseRealNameAuthInfoRequest, { + refetchOnWindowFocus: false + }); + type FormData = z.infer; const { - register, - handleSubmit, - setValue, - watch, - reset, - formState: { errors } - } = useForm({ + register: registerMain, + handleSubmit: handleMainSubmit, + reset: resetMain, + formState: { errors: mainErrors } + } = useForm>({ resolver: zodResolver(schema), - defaultValues: { - enterpriseName: '', - enterpriseQualification: null, - supportingMaterials: null + mode: 'onChange' + }); + + const { + register: registerVerification, + watch: watchVerification, + reset: resetVerification, + formState: { errors: verificationErrors } + } = useForm>({ + resolver: zodResolver(verificationSchema), + mode: 'onChange' + }); + + const transAmt = watchVerification('transAmt'); + + useEffect(() => { + if ( + enterpriseRealNameAuthInfo?.data && + enterpriseRealNameAuthInfo.data.paymentStatus === PAYMENTSTATUS.PROCESSING + ) { + const { key, accountBank, accountNo, keyName, usrName, contactInfo } = + enterpriseRealNameAuthInfo.data; + resetMain({ + key, + accountBank, + accountNo, + keyName, + usrName, + contactInfo + }); + } + }, [enterpriseRealNameAuthInfo?.data, resetMain]); + + const canPayment = enterpriseRealNameAuthInfo?.data?.paymentStatus !== PAYMENTSTATUS.PROCESSING; + + const canVerify = enterpriseRealNameAuthInfo?.data?.paymentStatus === PAYMENTSTATUS.PROCESSING; + + const canCancel = enterpriseRealNameAuthInfo?.data?.paymentStatus === PAYMENTSTATUS.PROCESSING; + + const canInput = enterpriseRealNameAuthInfo?.data?.paymentStatus !== PAYMENTSTATUS.PROCESSING; + + const remainingAttempts = enterpriseRealNameAuthInfo?.data?.remainingAttempts; + + const [errorMessage, setErrorMessage] = useState(''); + + const enterpriseRealNameAuthPaymentMutation = useMutation(enterpriseRealNameAuthPaymentRequest, { + onSuccess: (data) => { + if (data.code === 200 && data.data?.paymentStatus === PAYMENTSTATUS.PROCESSING) { + message({ + title: t('common:enterprise_realname_payment_success'), + status: 'success', + duration: 2000, + isClosable: true + }); + setErrorMessage(''); + queryClient.invalidateQueries(['enterpriseRealNameAuthInfo']); + } else { + message({ + title: t('common:enterprise_realname_payment_failed'), + status: 'error', + duration: 2000, + isClosable: true + }); + setErrorMessage(data.message || t('common:enterprise_realname_payment_failed')); + } + }, + onError: (error: any) => { + message({ + title: t('common:enterprise_realname_payment_failed'), + status: 'error', + duration: 2000, + isClosable: true + }); + setErrorMessage(error.message || t('common:enterprise_realname_payment_failed')); } }); - const enterpriseQualification = watch('enterpriseQualification'); - const supportingMaterials = watch('supportingMaterials'); - - const onDropEnterpriseQualification = useCallback( - (acceptedFiles: File[]) => { - setValue('enterpriseQualification', acceptedFiles[0], { shouldValidate: true }); - }, - [setValue] - ); - - const onDropCertificateMaterial = useCallback( - (acceptedFiles: File[]) => { - setValue('supportingMaterials', acceptedFiles[0], { shouldValidate: true }); - }, - [setValue] - ); - - const removeEnterpriseQualification = () => - setValue('enterpriseQualification', null, { shouldValidate: true }); - const removeSupportingMaterials = () => - setValue('supportingMaterials', null, { shouldValidate: true }); - - const enterpriseRealNameAuthMutation = useMutation(enterpriseRealNameAuthRequest, { + const enterpriseRealNameAuthVerifyMutation = useMutation(enterpriseRealNameAuthVerifyRequest, { onSuccess: (data) => { - if (data.code === 200) { + if (data.code === 200 && data.data?.authState === 'success') { message({ - title: t('common:upload_success'), + title: t('common:enterprise_realname_verify_success'), status: 'success', duration: 2000, - isClosable: true, - position: 'top' + isClosable: true + }); + + setErrorMessage(''); + + setSessionProp('user', { + ...useSessionStore.getState().session!.user!, + enterpriseRealName: data.data?.enterpriseRealName }); queryClient.invalidateQueries([session?.token, 'UserInfo']); - setSessionProp('user', { - ...useSessionStore.getState().session!.user!, - enterpriseVerificationStatus: data.data?.status - }); + queryClient.invalidateQueries(['enterpriseRealNameAuthInfo']); + resetMain(); + resetVerification(); - reset(); - - if (props.onFormSuccess && typeof props.onFormSuccess === 'function') { + if (props.onFormSuccess) { props.onFormSuccess(); } } else { message({ - title: data.message, + title: t('common:enterprise_realname_verify_failed'), status: 'error', - position: 'top', duration: 2000, isClosable: true }); + setErrorMessage( + data.message ? t(data.message as any) : t('common:enterprise_realname_verify_failed') + ); } }, - onError: (error: Error) => { + onError: (error: any) => { message({ - title: error.message, + title: t('common:enterprise_realname_verify_failed'), status: 'error', - position: 'top', duration: 2000, isClosable: true }); + setErrorMessage(error.message || t('common:enterprise_realname_verify_failed')); } }); - const onValidate = async (data: { - enterpriseName: string; - enterpriseQualification: File | null; - supportingMaterials: File | null; - }) => { - if (!data.enterpriseQualification || !data.supportingMaterials) { + const enterpriseRealNameAuthCancelMutation = useMutation(enterpriseRealNameAuthCancelRequest, { + onSuccess: (data) => { + if (data.code === 200 && data.data?.paymentStatus === PAYMENTSTATUS.CANCEL) { + message({ + title: t('common:enterprise_realname_cancel_success'), + status: 'success', + duration: 2000, + isClosable: true + }); + setErrorMessage(''); + queryClient.invalidateQueries(['enterpriseRealNameAuthInfo']); + } else { + message({ + title: t('common:enterprise_realname_cancel_failed'), + status: 'error', + duration: 2000, + isClosable: true + }); + setErrorMessage(t('common:enterprise_realname_cancel_failed')); + } + }, + onError: (error: any) => { message({ - title: t('common:please_fill_all_fields'), - status: 'warning', - position: 'top', + title: t('common:enterprise_realname_cancel_failed'), + status: 'error', duration: 2000, isClosable: true }); + setErrorMessage(error.message || t('common:enterprise_realname_cancel_failed')); + } + }); + const handleVerifyClick = () => { + if (!transAmt || !/^\d{1,3}$/.test(transAmt)) { + message({ + title: t('common:invalid_verification_amount'), + status: 'error', + duration: 2000, + isClosable: true + }); + setErrorMessage(t('common:invalid_verification_amount')); return; } - const formData = new FormData(); - formData.append('enterpriseName', data.enterpriseName); - formData.append('enterpriseQualification', data.enterpriseQualification); - formData.append('supportingMaterials', data.supportingMaterials); - enterpriseRealNameAuthMutation.mutate(formData); + + enterpriseRealNameAuthVerifyMutation.mutate({ transAmt }); }; - const onInvalid = () => { - const firstErrorMessage = Object.values(errors)[0]?.message; - if (firstErrorMessage) { - message({ - title: firstErrorMessage, - status: 'error', - position: 'top', - duration: 2000, - isClosable: true - }); - } - }; - - const onSubmit = handleSubmit(onValidate, onInvalid); + if (enterpriseRealNameAuthInfoLoading) { + return ( +
+ + {t('common:loading')} +
+ ); + } return ( -
- + { + enterpriseRealNameAuthPaymentMutation.mutate(data); + })} > - - + - + + + + + + {t('common:enterprise_keyname')} + + + + + {mainErrors.keyName?.message} + + + + + + + {t('common:enterprise_key')} + + + + + {mainErrors.key?.message} + + + + + + + {t('common:legal_person')} + + + + + {mainErrors.usrName?.message} + + + + + + + {t('common:account_number')} + + + + + {mainErrors.accountNo?.message} + + + + + + + {t('common:bank_name')} + + + + + {mainErrors.accountBank?.message} + + + + + + + {t('common:contact_info')} + + + + + {mainErrors.contactInfo?.message} + + + + {(errorMessage || !canPayment) && ( + + + {errorMessage || t('common:verification_amount_tips2')} + + + )} + + + {t('common:verification_amount')} + + + + + + + + {verificationErrors.transAmt?.message} + + + + {t('common:verification_amount_tips')} + + {t('common:link_to_workorder')} + + {i18n.language === 'zh' && '。'} + + + + + + + - + {t('common:cancel')} + + + - -
+
+ ); } diff --git a/frontend/desktop/src/components/desktop_content/index.tsx b/frontend/desktop/src/components/desktop_content/index.tsx index 345a330c6..173cb4c45 100644 --- a/frontend/desktop/src/components/desktop_content/index.tsx +++ b/frontend/desktop/src/components/desktop_content/index.tsx @@ -127,7 +127,7 @@ export default function Desktop(props: any) { useEffect(() => { if (infoData.isSuccess && commonConfig?.realNameAuthEnabled) { - if (!infoData?.data?.realName && infoData?.data?.enterpriseVerificationStatus !== 'Success') { + if (!infoData?.data?.realName && !infoData?.data?.enterpriseRealName) { realNameAuthNotificationIdRef.current = realNameAuthNotification({ duration: null, isClosable: true diff --git a/frontend/desktop/src/components/signin/auth/usePassword.tsx b/frontend/desktop/src/components/signin/auth/usePassword.tsx index be8ed7cec..b95c4604b 100644 --- a/frontend/desktop/src/components/signin/auth/usePassword.tsx +++ b/frontend/desktop/src/components/signin/auth/usePassword.tsx @@ -95,8 +95,6 @@ export default function usePassword({ userId: payload.userId, userUid: payload.userUid, realName: infoData.data?.info.realName || undefined, - enterpriseVerificationStatus: - infoData.data?.info.enterpriseVerificationStatus || undefined, enterpriseRealName: infoData.data?.info.enterpriseRealName || undefined, userRestrictedLevel: infoData.data?.info.userRestrictedLevel || undefined }, diff --git a/frontend/desktop/src/components/signin/auth/useWechat.tsx b/frontend/desktop/src/components/signin/auth/useWechat.tsx index ea2084389..759830f2d 100644 --- a/frontend/desktop/src/components/signin/auth/useWechat.tsx +++ b/frontend/desktop/src/components/signin/auth/useWechat.tsx @@ -54,8 +54,6 @@ export default function useWechat() { userId: payload.userId, realName: infoData.data?.info.realName || undefined, userRestrictedLevel: infoData.data?.info.userRestrictedLevel || undefined, - enterpriseVerificationStatus: - infoData.data?.info.enterpriseVerificationStatus || undefined, enterpriseRealName: infoData.data?.info.enterpriseRealName || undefined }, // @ts-ignore diff --git a/frontend/desktop/src/pages/api/account/enterpriseRealName.ts b/frontend/desktop/src/pages/api/account/enterpriseRealName.ts new file mode 100644 index 000000000..e818e7fca --- /dev/null +++ b/frontend/desktop/src/pages/api/account/enterpriseRealName.ts @@ -0,0 +1,320 @@ +import { jsonRes } from '@/services/backend/response'; +import { enableEnterpriseRealNameAuth } from '@/services/enable'; +import type { NextApiRequest, NextApiResponse } from 'next'; +import { generateAuthenticationToken, verifyAccessToken } from '@/services/backend/auth'; +import { globalPrisma } from '@/services/backend/db/init'; +import { z } from 'zod'; +import { PAYMENTSTATUS } from '@/types/response/enterpriseRealName'; +import { EnterpriseAuthInfo } from '@/types/response/enterpriseRealName'; +import { AdditionalInfo } from '@/types/response/enterpriseRealName'; +import { AccessTokenPayload } from '@/types/token'; + +type JsonValue = string | number | boolean | object | null; + +type RealNameAuthProvider = { + id: string; + backend: string; + authType: string; + maxFailedTimes: number; + config: JsonValue; + createdAt: Date; + updatedAt: Date; +}; + +type UnionPay3060Config = { + api: string; +}; + +interface ApiError { + detail: any; + errorId: string; + message: string; + timestamp: number; + code: string; +} + +interface EnterpriseAuthResponse { + subBank: string; + transAmt: string; + orderId: string; + isCharged: boolean; + legalPersonName: string; + isTransactionSuccess: boolean; + key: string; + enterpriseName: string; + accountCity: string; + respCode: string; + respMsg: string; + accountProv: string; + accountBank: string; + accountNo: string; +} + +interface ApiResponse { + error?: ApiError; + data?: EnterpriseAuthResponse; + success: boolean; +} + +const schema = z.object({ + key: z.string().min(1), + accountBank: z.string().min(1), + accountNo: z.string().min(1), + keyName: z.string().min(1), + usrName: z.string().min(1), + contactInfo: z.string().min(1).regex(/^\d+$/, 'Contact info must contain only numbers') +}); + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + if (!enableEnterpriseRealNameAuth) { + console.error('enterpriseRealNameAuth: enterprise real name authentication not enabled'); + return jsonRes(res, { code: 503, message: 'Enterprise real name authentication not enabled' }); + } + + const payload = await verifyAccessToken(req.headers); + if (!payload) return jsonRes(res, { code: 401, message: 'Token is invalid' }); + + switch (req.method) { + case 'GET': + return handleGet(req, res, payload.userUid); + case 'POST': + return handlePost(req, res, payload.userUid, payload); + case 'PATCH': + return handlePatch(req, res, payload.userUid); + default: + console.error('enterpriseRealNameAuth: Method not allowed'); + return jsonRes(res, { code: 405, message: 'Method not allowed' }); + } +} + +async function handleGet(req: NextApiRequest, res: NextApiResponse, userUid: string) { + try { + const realNameAuthProvider: RealNameAuthProvider | null = + await globalPrisma.realNameAuthProvider.findFirst({ + where: { + backend: 'UNIONPAY', + authType: '3060' + } + }); + + if (!realNameAuthProvider) { + throw new Error('enterpriseRealNameAuth: Real name authentication provider not found'); + } + + const info = await globalPrisma.enterpriseRealNameInfo.findUnique({ + where: { userUid } + }); + + if (!info || !info.additionalInfo) { + return jsonRes(res, { code: 200, data: { authTimes: realNameAuthProvider.maxFailedTimes } }); + } + + const additionalInfo = info.additionalInfo as unknown as AdditionalInfo; + + const response: EnterpriseAuthInfo = { + paymentStatus: additionalInfo.paymentStatus, + key: additionalInfo.key, + accountBank: additionalInfo.accountBank, + accountNo: additionalInfo.accountNo, + keyName: additionalInfo.keyName, + usrName: additionalInfo.usrName, + contactInfo: additionalInfo.contactInfo, + remainingAttempts: realNameAuthProvider.maxFailedTimes - (additionalInfo.authTimes || 0) + }; + + return jsonRes(res, { code: 200, data: response }); + } catch (error) { + console.error('Error fetching enterprise auth info:', error); + return jsonRes(res, { code: 500, message: 'Internal server error' }); + } +} + +async function handlePost( + req: NextApiRequest, + res: NextApiResponse, + userUid: string, + payload: AccessTokenPayload +) { + try { + const realNameAuthProvider: RealNameAuthProvider | null = + await globalPrisma.realNameAuthProvider.findFirst({ + where: { + backend: 'UNIONPAY', + authType: '3060' + } + }); + + if (!realNameAuthProvider) { + throw new Error('enterpriseRealNameAuth: Real name authentication provider not found'); + } + + const config: UnionPay3060Config = realNameAuthProvider.config as UnionPay3060Config; + + if (!config) { + throw new Error('enterpriseRealNameAuth: Real name authentication configuration not found'); + } + + const enterpriseRealNameAuthApi = config.api; + + const enterpriseRealName = await globalPrisma.enterpriseRealNameInfo.findUnique({ + where: { userUid } + }); + + if (enterpriseRealName && enterpriseRealName.isVerified) { + return jsonRes(res, { + code: 400, + message: 'Enterprise real name authentication has been completed' + }); + } + + if ( + enterpriseRealName && + enterpriseRealName.additionalInfo && + (enterpriseRealName.additionalInfo as unknown as AdditionalInfo).authTimes >= + realNameAuthProvider.maxFailedTimes + ) { + return jsonRes(res, { + code: 400, + message: 'Enterprise real name authentication has reached the maximum number of attempts' + }); + } + + const validationResult = schema.safeParse(req.body); + if (!validationResult.success) { + return jsonRes(res, { + code: 400, + message: 'Invalid request body', + data: validationResult.error.issues + }); + } + + const data = validationResult.data; + + const globalToken = generateAuthenticationToken({ + userUid: payload.userUid, + userId: payload.userId, + regionUid: payload.regionUid + }); + + const response = await fetch(enterpriseRealNameAuthApi, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${globalToken}` + }, + cache: 'no-store', + body: JSON.stringify({ + key: data.key, + accountBank: data.accountBank, + accountNo: data.accountNo, + keyName: data.keyName, + usrName: data.usrName + }) + }); + + const apiResponse: ApiResponse = await response.json(); + + if (!apiResponse.success) { + return jsonRes(res, { + code: 400, + message: apiResponse?.error?.message || 'API request failed' + }); + } + + if (!apiResponse.data?.isTransactionSuccess || !apiResponse.data?.transAmt) { + return jsonRes(res, { + code: 400, + message: `request failed, code: ${apiResponse.data?.respCode}, message: ${apiResponse.data?.respMsg}` + }); + } + + if (!response.ok) { + throw new Error(`API request failed with status ${response.status}`); + } + + await globalPrisma.enterpriseRealNameInfo.upsert({ + where: { userUid }, + update: { + enterpriseName: apiResponse.data.enterpriseName, + isVerified: false, + additionalInfo: { + paymentStatus: PAYMENTSTATUS.PROCESSING, + key: apiResponse.data.key, + accountBank: apiResponse.data.accountBank, + accountNo: apiResponse.data.accountNo, + keyName: apiResponse.data.enterpriseName, + usrName: apiResponse.data.legalPersonName, + contactInfo: data.contactInfo, + transAmt: apiResponse.data.transAmt, + authTimes: (enterpriseRealName?.additionalInfo as unknown as AdditionalInfo)?.authTimes + ? (enterpriseRealName?.additionalInfo as unknown as AdditionalInfo).authTimes + 1 + : 1 + } + }, + create: { + userUid, + enterpriseName: apiResponse.data.enterpriseName, + isVerified: false, + additionalInfo: { + paymentStatus: PAYMENTSTATUS.PROCESSING, + key: apiResponse.data.key, + accountBank: apiResponse.data.accountBank, + accountNo: apiResponse.data.accountNo, + keyName: apiResponse.data.enterpriseName, + usrName: apiResponse.data.legalPersonName, + contactInfo: data.contactInfo, + transAmt: apiResponse.data.transAmt, + authTimes: 1 + } + } + }); + + return jsonRes(res, { + code: 200, + message: 'Enterprise auth request processed successfully', + data: { + paymentStatus: PAYMENTSTATUS.PROCESSING + } + }); + } catch (error) { + console.error('Error processing enterprise auth request:', error); + return jsonRes(res, { code: 500, message: 'Internal server error' }); + } +} + +async function handlePatch(req: NextApiRequest, res: NextApiResponse, userUid: string) { + try { + const info = await globalPrisma.enterpriseRealNameInfo.findUnique({ + where: { userUid } + }); + + if (!info) { + return jsonRes(res, { code: 404, message: 'Enterprise auth info not found' }); + } + + if (info.isVerified) { + return jsonRes(res, { code: 400, message: 'Enterprise auth has been completed' }); + } + + await globalPrisma.enterpriseRealNameInfo.update({ + where: { userUid }, + data: { + additionalInfo: { + ...(info.additionalInfo as object), + paymentStatus: PAYMENTSTATUS.CANCEL + } + } + }); + + return jsonRes(res, { + code: 200, + message: 'Payment status updated successfully', + data: { + paymentStatus: PAYMENTSTATUS.CANCEL + } + }); + } catch (error) { + console.error('Error updating payment status:', error); + return jsonRes(res, { code: 500, message: 'Internal server error' }); + } +} diff --git a/frontend/desktop/src/pages/api/account/enterpriseRealNameAuth.ts b/frontend/desktop/src/pages/api/account/enterpriseRealNameAuth.ts deleted file mode 100644 index dcbb683ad..000000000 --- a/frontend/desktop/src/pages/api/account/enterpriseRealNameAuth.ts +++ /dev/null @@ -1,245 +0,0 @@ -import { jsonRes } from '@/services/backend/response'; -import { enableEnterpriseRealNameAuth } from '@/services/enable'; -import type { NextApiRequest, NextApiResponse } from 'next'; -import { verifyAccessToken } from '@/services/backend/auth'; -import { globalPrisma } from '@/services/backend/db/init'; -import { RealNameOSSConfigType } from '@/types'; -import * as Minio from 'minio'; -import formidable, { Fields, Files, File, Part } from 'formidable'; -import path from 'path'; -import Formidable from 'formidable/Formidable'; -import fs from 'fs/promises'; - -export const config = { - api: { - bodyParser: false - } -}; - -const realNameOSS: RealNameOSSConfigType = global.AppConfig.realNameOSS; - -const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB -const ALLOWED_FILE_TYPES = ['image/jpeg', 'image/png', 'application/pdf']; - -export default async function handler(req: NextApiRequest, res: NextApiResponse) { - if (!enableEnterpriseRealNameAuth) { - console.error('enterpriseRealNameAuth: enterprise real name authentication not enabled'); - return jsonRes(res, { code: 503, message: 'Enterprise real name authentication not enabled' }); - } - - if (req.method !== 'POST') { - console.error('enterpriseRealNameAuth: Method not allowed'); - return jsonRes(res, { code: 405, message: 'Method not allowed' }); - } - - const payload = await verifyAccessToken(req.headers); - if (!payload) return jsonRes(res, { code: 401, message: 'Token is invalid' }); - - if (!realNameOSS) { - return jsonRes(res, { - code: 500, - message: 'Real name authentication oss configuration not found' - }); - } - - try { - const userUid = payload.userUid; - - const form = formidable({ - multiples: false, - keepExtensions: true, - maxFileSize: MAX_FILE_SIZE, - filter: function (part: Part): boolean { - return part.mimetype !== null && ALLOWED_FILE_TYPES.includes(part.mimetype); - }, - filename: function (name: string, ext: string, part: Part, form: Formidable): string { - const sanitizedName = sanitizeFilename(part.originalFilename || 'unnamed'); - return sanitizedName; - } - }); - - const formData = await parseFormData(req, form); - const { fields, files } = formData; - - const enterpriseName = fields.enterpriseName?.[0]; - - if ((enterpriseName && enterpriseName.length < 1) || enterpriseName.length > 20) { - return jsonRes(res, { - code: 400, - message: 'Enterprise name must be between 1 and 20 characters' - }); - } - - if (!files.enterpriseQualification?.[0] || !files.supportingMaterials?.[0]) { - return jsonRes(res, { - code: 400, - message: 'Enterprise qualification and supporting materials are required' - }); - } - - if ( - files && - files.enterpriseQualification?.[0] && - files.enterpriseQualification?.[0].size > MAX_FILE_SIZE - ) { - return jsonRes(res, { - code: 400, - message: 'Enterprise qualification file size exceeds the maximum limit' - }); - } - - if ( - files && - files.supportingMaterials?.[0] && - files.supportingMaterials?.[0].size > MAX_FILE_SIZE - ) { - return jsonRes(res, { - code: 400, - message: 'Supporting materials file size exceeds the maximum limit' - }); - } - - // Check if EnterpriseRealNameInfo exists - const existingInfo = await globalPrisma.enterpriseRealNameInfo.findUnique({ - where: { userUid } - }); - - if (!existingInfo) { - // Create new EnterpriseRealNameInfo - const ossPaths = await uploadFiles(userUid, files); - await createEnterpriseRealNameInfo(userUid, enterpriseName, ossPaths); - return jsonRes(res, { - code: 200, - message: 'Enterprise real name authentication submitted successfully', - data: { status: 'Pending' } - }); - } - - // Handle existing EnterpriseRealNameInfo cases - switch (existingInfo.verificationStatus) { - case 'Pending': - return jsonRes(res, { code: 400, message: 'Authentication is under review' }); - case 'Success': - return jsonRes(res, { code: 400, message: 'Cannot authenticate multiple times' }); - case 'Failed': - // Re-upload files and update EnterpriseRealNameInfo - const newOssPaths = await uploadFiles(userUid, files); - await updateEnterpriseRealNameInfo(existingInfo.id, enterpriseName, newOssPaths); - return jsonRes(res, { - code: 200, - data: { status: 'Pending' }, - message: 'Enterprise real name authentication resubmitted successfully' - }); - default: - return jsonRes(res, { code: 500, message: 'Invalid verification status' }); - } - } catch (error) { - console.error('enterpriseRealNameAuth: Internal error', error); - return jsonRes(res, { code: 500, message: 'The server has encountered an error' }); - } -} - -// Helper functions -async function parseFormData(req: NextApiRequest, form: Formidable): Promise { - return new Promise((resolve, reject) => { - form.parse(req, (err: Error, fields: Fields, files: Files) => { - if (err) { - reject(err); - } else { - resolve({ fields, files }); - } - }); - }); -} - -function sanitizeFilename(filename: string): string { - // Remove any path components - const basename = path.basename(filename); - // Define a blacklist of malicious characters - const blacklist = /[<>:"/\\|?*\x00-\x1F]/g; - // Replace blacklisted characters with underscores - return basename.replace(blacklist, '_'); -} - -async function uploadFiles(userUid: string, files: Files): Promise { - const minioConfig: Minio.ClientOptions = { - endPoint: realNameOSS.endpoint, - accessKey: realNameOSS.accessKey, - secretKey: realNameOSS.accessKeySecret, - useSSL: realNameOSS.ssl - }; - const minioClient = new Minio.Client(minioConfig); - - const filesToUpload = [ - { file: files.enterpriseQualification?.[0], name: 'enterpriseQualification' }, - { file: files.supportingMaterials?.[0], name: 'supportingMaterials' } - ].filter((item) => item.file !== null); - - const uploadPromises = filesToUpload.map((item) => - uploadFile(minioClient, userUid, item.file!, item.name) - ); - - return await Promise.all(uploadPromises); -} - -async function uploadFile( - minioClient: Minio.Client, - userUid: string, - file: File, - fileType: string -): Promise { - const timestamp = Date.now(); - const fileName = `${timestamp}_${fileType}_${file.newFilename}`; - const filePath = `/${userUid}/${fileName}`; - try { - await minioClient.fPutObject(realNameOSS.enterpriseRealNameBucket, filePath, file.filepath, { - 'Content-Type': file.mimetype || 'application/octet-stream' - }); - - // Check if the file exists before attempting to delete it - try { - await fs.access(file.filepath); - // If no error is thrown, the file exists, so we can delete it - await fs.unlink(file.filepath); - console.debug(`File ${file.filepath} has been deleted.`); - } catch (accessError) { - // If an error is thrown, the file doesn't exist - console.debug(`File ${file.filepath} does not exist or is not accessible.`); - } - } catch (error) { - console.error('EnterpriseRealNameAuth uploadFile: Error uploading file', error); - throw error; - } - - return filePath; -} - -async function createEnterpriseRealNameInfo( - userUid: string, - enterpriseName: string, - ossPaths: string[] -) { - await globalPrisma.enterpriseRealNameInfo.create({ - data: { - userUid, - enterpriseName: enterpriseName, - supportingMaterials: { ossPaths: ossPaths }, // Remaining paths for supportingMaterials - verificationStatus: 'Pending' - } - }); -} - -async function updateEnterpriseRealNameInfo( - id: string, - enterpriseName: string, - ossPaths: string[] -) { - await globalPrisma.enterpriseRealNameInfo.update({ - where: { id }, - data: { - enterpriseName: enterpriseName, - supportingMaterials: { ossPaths: ossPaths }, - verificationStatus: 'Pending' - } - }); -} diff --git a/frontend/desktop/src/pages/api/account/enterpriseRealNameVerify.ts b/frontend/desktop/src/pages/api/account/enterpriseRealNameVerify.ts new file mode 100644 index 000000000..cf1e7bbd1 --- /dev/null +++ b/frontend/desktop/src/pages/api/account/enterpriseRealNameVerify.ts @@ -0,0 +1,95 @@ +import { jsonRes } from '@/services/backend/response'; +import { enableEnterpriseRealNameAuth } from '@/services/enable'; +import type { NextApiRequest, NextApiResponse } from 'next'; +import { verifyAccessToken } from '@/services/backend/auth'; +import { globalPrisma } from '@/services/backend/db/init'; +import { z } from 'zod'; +import { PAYMENTSTATUS } from '@/types/response/enterpriseRealName'; +import { AdditionalInfo } from '@/types/response/enterpriseRealName'; + +const schema = z.object({ + transAmt: z.string().min(1) +}); + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + if (!enableEnterpriseRealNameAuth) { + console.error('enterpriseRealNameVerify: enterprise real name authentication not enabled'); + return jsonRes(res, { code: 503, message: 'Enterprise real name authentication not enabled' }); + } + + if (req.method !== 'POST') { + console.error('enterpriseRealNameVerify: Method not allowed'); + return jsonRes(res, { code: 405, message: 'Method not allowed' }); + } + + const payload = await verifyAccessToken(req.headers); + if (!payload) return jsonRes(res, { code: 401, message: 'Token is invalid' }); + + try { + const validationResult = schema.safeParse(req.body); + if (!validationResult.success) { + return jsonRes(res, { + code: 400, + message: 'Invalid request body', + data: validationResult.error.issues + }); + } + + const { transAmt } = validationResult.data; + + const info = await globalPrisma.enterpriseRealNameInfo.findUnique({ + where: { userUid: payload.userUid } + }); + + if (!info || !info.additionalInfo) { + return jsonRes(res, { + code: 404, + data: { + authState: 'failed' + }, + message: 'Enterprise auth info not found' + }); + } + + if (info.isVerified) { + return jsonRes(res, { + code: 400, + message: 'Enterprise real name authentication already verified' + }); + } + + const additionalInfo = info.additionalInfo as unknown as AdditionalInfo; + + if (additionalInfo.transAmt === transAmt) { + await globalPrisma.enterpriseRealNameInfo.update({ + where: { userUid: payload.userUid }, + data: { + isVerified: true, + additionalInfo: { + ...additionalInfo, + paymentStatus: PAYMENTSTATUS.SUCCESS + } + } + }); + + return jsonRes(res, { + code: 200, + data: { + authState: 'success', + enterpriseRealName: additionalInfo.keyName + } + }); + } + + return jsonRes(res, { + code: 200, + message: 'transAmt_not_match', + data: { + authState: 'failed' + } + }); + } catch (error) { + console.error('Error processing verify request:', error); + return jsonRes(res, { code: 500, message: 'Internal server error' }); + } +} diff --git a/frontend/desktop/src/pages/api/account/faceIdRealNameAuthCallback.ts b/frontend/desktop/src/pages/api/account/faceIdRealNameAuthCallback.ts index a91e0aec8..03e6f1155 100644 --- a/frontend/desktop/src/pages/api/account/faceIdRealNameAuthCallback.ts +++ b/frontend/desktop/src/pages/api/account/faceIdRealNameAuthCallback.ts @@ -106,6 +106,10 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) return jsonRes(res, { code: 400, message: 'User real name info not found' }); } + if (userRealNameInfo.isVerified) { + return jsonRes(res, { code: 400, message: 'User real name info already verified' }); + } + let additionalInfo: AdditionalInfo = userRealNameInfo.additionalInfo; additionalInfo.faceRecognition.callback.isUsed = true; @@ -351,16 +355,16 @@ async function getUserRealNameInfo( credential: { secretId: config.secretId, secretKey: config.secretKey - }, - region: '', - profile: { - signMethod: 'HmacSHA256', - httpProfile: { - endpoint: 'faceid.tencentcloudapi.com', - reqMethod: 'POST', - reqTimeout: 30 // Request timeout, default 60s - } } + // region: '', + // profile: { + // signMethod: 'HmacSHA256', + // httpProfile: { + // endpoint: 'faceid.tencentcloudapi.com', + // reqMethod: 'POST', + // reqTimeout: 30 // Request timeout, default 60s + // } + // } }); const params = { diff --git a/frontend/desktop/src/pages/api/account/generateRealNameQRcodeUri.ts b/frontend/desktop/src/pages/api/account/generateRealNameQRcodeUri.ts index 3120f8a85..d18461172 100644 --- a/frontend/desktop/src/pages/api/account/generateRealNameQRcodeUri.ts +++ b/frontend/desktop/src/pages/api/account/generateRealNameQRcodeUri.ts @@ -179,16 +179,16 @@ async function generateRealNameQRcodeUri( credential: { secretId: config.secretId, secretKey: config.secretKey - }, - region: '', - profile: { - signMethod: 'HmacSHA256', - httpProfile: { - endpoint: 'faceid.tencentcloudapi.com', - reqMethod: 'POST', - reqTimeout: 30 // Request timeout, default 60s - } } + // region: '', + // profile: { + // signMethod: 'HmacSHA256', + // httpProfile: { + // endpoint: 'faceid.tencentcloudapi.com', + // reqMethod: 'POST', + // reqTimeout: 30 // Request timeout, default 60s + // } + // } }); const params = { diff --git a/frontend/desktop/src/pages/api/auth/info.ts b/frontend/desktop/src/pages/api/auth/info.ts index cdb11082c..1532dc4b4 100644 --- a/frontend/desktop/src/pages/api/auth/info.ts +++ b/frontend/desktop/src/pages/api/auth/info.ts @@ -73,7 +73,6 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) id: string; name: string; realName?: string; - enterpriseVerificationStatus?: string; enterpriseRealName?: string; userRestrictedLevel?: number; } = { @@ -105,13 +104,16 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) })) }; - if (realNameInfo && realNameInfo.isVerified) { - info.realName = realNameInfo.realName || undefined; + if (realNameInfo && realNameInfo.isVerified && realNameInfo.realName) { + info.realName = realNameInfo.realName; } - if (enterpriseRealNameInfo) { - info.enterpriseVerificationStatus = enterpriseRealNameInfo.verificationStatus || undefined; - info.enterpriseRealName = enterpriseRealNameInfo.enterpriseName || undefined; + if ( + enterpriseRealNameInfo && + enterpriseRealNameInfo.isVerified && + enterpriseRealNameInfo.enterpriseName + ) { + info.enterpriseRealName = enterpriseRealNameInfo.enterpriseName; } if (restrictedUser) { diff --git a/frontend/desktop/src/pages/api/platform/getCommonConfig.ts b/frontend/desktop/src/pages/api/platform/getCommonConfig.ts index 87733b993..517b1cba3 100644 --- a/frontend/desktop/src/pages/api/platform/getCommonConfig.ts +++ b/frontend/desktop/src/pages/api/platform/getCommonConfig.ts @@ -23,8 +23,7 @@ function genResCommonClientConfig(common: CommonConfigType): CommonClientConfigT realNameReward: common.realNameReward || 0, guideEnabled: !!common.guideEnabled, rechargeEnabled: !!common.rechargeEnabled, - cfSiteKey: common.cfSiteKey || '', - enterpriseSupportingMaterials: common.enterpriseSupportingMaterials || '' + cfSiteKey: common.cfSiteKey || '' }; } export async function getCommonClientConfig(): Promise { diff --git a/frontend/desktop/src/types/response/enterpriseRealName.ts b/frontend/desktop/src/types/response/enterpriseRealName.ts new file mode 100644 index 000000000..a480d2879 --- /dev/null +++ b/frontend/desktop/src/types/response/enterpriseRealName.ts @@ -0,0 +1,29 @@ +export enum PAYMENTSTATUS { + PROCESSING = 'PROCESSING', + FAILED = 'FAILED', + SUCCESS = 'SUCCESS', + CANCEL = 'USER_CANCEL' +} + +export interface EnterpriseAuthInfo { + paymentStatus: PAYMENTSTATUS; + key: string; + accountBank: string; + accountNo: string; + keyName: string; + usrName: string; + contactInfo: string; + remainingAttempts: number; +} + +export interface AdditionalInfo { + paymentStatus: PAYMENTSTATUS; + key: string; + accountBank: string; + accountNo: string; + keyName: string; + usrName: string; + contactInfo: string; + transAmt: string; + authTimes: number; +} diff --git a/frontend/desktop/src/types/session.ts b/frontend/desktop/src/types/session.ts index 043a45bae..a8d762d59 100644 --- a/frontend/desktop/src/types/session.ts +++ b/frontend/desktop/src/types/session.ts @@ -8,7 +8,6 @@ export type OAuthToken = { export type UserInfo = { readonly userRestrictedLevel?: number; readonly realName?: string; - readonly enterpriseVerificationStatus?: string; readonly enterpriseRealName?: string; readonly k8s_username: string; readonly name: string; diff --git a/frontend/desktop/src/types/system.ts b/frontend/desktop/src/types/system.ts index dac507ced..91956b17c 100644 --- a/frontend/desktop/src/types/system.ts +++ b/frontend/desktop/src/types/system.ts @@ -9,7 +9,6 @@ export type CloudConfigType = { export type CommonConfigType = { enterpriseRealNameAuthEnabled: boolean; - enterpriseSupportingMaterials: string; realNameAuthEnabled: boolean; realNameReward: number; guideEnabled: boolean; @@ -220,7 +219,6 @@ export type AppClientConfigType = { export const DefaultCommonClientConfig: CommonClientConfigType = { enterpriseRealNameAuthEnabled: false, - enterpriseSupportingMaterials: '', realNameAuthEnabled: false, realNameReward: 0, guideEnabled: false, diff --git a/frontend/desktop/src/types/token.ts b/frontend/desktop/src/types/token.ts index f2852f59b..9486c57b6 100644 --- a/frontend/desktop/src/types/token.ts +++ b/frontend/desktop/src/types/token.ts @@ -1,6 +1,7 @@ export type AuthenticationTokenPayload = { userUid: string; userId: string; + regionUid?: string; }; export type AccessTokenPayload = { regionUid: string; diff --git a/frontend/desktop/src/utils/sessionConfig.ts b/frontend/desktop/src/utils/sessionConfig.ts index c1c44563f..a594900ef 100644 --- a/frontend/desktop/src/utils/sessionConfig.ts +++ b/frontend/desktop/src/utils/sessionConfig.ts @@ -22,7 +22,6 @@ export const sessionConfig = async ({ user: { userRestrictedLevel: infoData.data?.info.userRestrictedLevel || undefined, realName: infoData.data?.info.realName || undefined, - enterpriseVerificationStatus: infoData.data?.info.enterpriseVerificationStatus || undefined, enterpriseRealName: infoData.data?.info.enterpriseRealName || undefined, k8s_username: payload.userCrName, name: infoData.data?.info.nickname || '', diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 729396949..826cb7e23 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -135,9 +135,6 @@ importers: eslint-config-next: specifier: 13.3.0 version: 13.3.0(eslint@8.38.0)(typescript@5.2.2) - formidable: - specifier: ^3.5.1 - version: 3.5.1 framer-motion: specifier: ^10.16.4 version: 10.16.5(react-dom@18.2.0)(react@18.2.0) @@ -247,9 +244,6 @@ importers: '@testing-library/react': specifier: ^14.0.0 version: 14.1.2(react-dom@18.2.0)(react@18.2.0) - '@types/formidable': - specifier: ^3.4.5 - version: 3.4.5 '@types/jest': specifier: ^29.5.10 version: 29.5.10