From a884a80fcbcbc3df4789ab87f7559a48d4d2c818 Mon Sep 17 00:00:00 2001 From: limbo <43649186+HUAHUAI23@users.noreply.github.com> Date: Thu, 10 Oct 2024 14:02:51 +0800 Subject: [PATCH] feat(desktop): add face auth and enterprise auth (#5124) * add face auth * ok * ok * ok * ok * ok * test * ok * ok * ok * ok --- frontend/desktop/.gitignore | 1 + frontend/desktop/.prettierrc.js | 4 +- frontend/desktop/data/config.yaml | 9 + frontend/desktop/package.json | 3 + .../migration.sql | 19 + frontend/desktop/prisma/global/schema.prisma | 40 +- .../desktop/public/locales/en/common.json | 31 +- .../desktop/public/locales/zh/common.json | 32 +- frontend/desktop/src/api/auth.ts | 32 +- .../account/AccountCenter/index.tsx | 12 +- .../src/components/account/RealNameModal.tsx | 1117 +++++++++++------ .../src/components/desktop_content/index.tsx | 12 +- .../desktop/src/components/icons/index.tsx | 84 ++ .../components/signin/auth/usePassword.tsx | 3 + .../src/components/signin/auth/useWechat.tsx | 5 +- .../api/account/enterpriseRealNameAuth.ts | 245 ++++ .../api/account/faceIdRealNameAuthCallback.ts | 308 +++++ .../api/account/generateRealNameQRcodeUri.ts | 210 ++++ .../pages/api/account/getFaceAuthStatus.ts | 144 +++ .../src/pages/api/account/realNameAuth.ts | 219 ---- frontend/desktop/src/pages/api/auth/info.ts | 69 +- .../src/pages/api/platform/getAuthConfig.ts | 2 +- .../src/pages/api/platform/getCloudConfig.ts | 2 +- .../src/pages/api/platform/getCommonConfig.ts | 6 +- .../src/pages/api/platform/getLayoutConfig.ts | 2 +- frontend/desktop/src/services/enable.ts | 2 + frontend/desktop/src/types/session.ts | 2 + frontend/desktop/src/types/system.ts | 16 + frontend/desktop/src/utils/sessionConfig.ts | 2 + frontend/pnpm-lock.yaml | 9 + 30 files changed, 1928 insertions(+), 714 deletions(-) create mode 100644 frontend/desktop/prisma/global/migrations/20240928050904_add_enterprise_real_name_info_table/migration.sql create mode 100644 frontend/desktop/src/pages/api/account/enterpriseRealNameAuth.ts create mode 100644 frontend/desktop/src/pages/api/account/faceIdRealNameAuthCallback.ts create mode 100644 frontend/desktop/src/pages/api/account/generateRealNameQRcodeUri.ts create mode 100644 frontend/desktop/src/pages/api/account/getFaceAuthStatus.ts delete mode 100644 frontend/desktop/src/pages/api/account/realNameAuth.ts diff --git a/frontend/desktop/.gitignore b/frontend/desktop/.gitignore index 0ee16aefb..3d4d46cf7 100644 --- a/frontend/desktop/.gitignore +++ b/frontend/desktop/.gitignore @@ -42,4 +42,5 @@ yalc.lock config.yaml .env +data/config.local.yaml #/prisma/region/generated/ diff --git a/frontend/desktop/.prettierrc.js b/frontend/desktop/.prettierrc.js index b070d785e..3e3db788f 100644 --- a/frontend/desktop/.prettierrc.js +++ b/frontend/desktop/.prettierrc.js @@ -19,10 +19,10 @@ module.exports = { endOfLine: 'lf', overrides: [ { - files: 'config.yaml.local', + files: 'config.local.yaml', options: { parser: 'yaml' } } ] -}; +} diff --git a/frontend/desktop/data/config.yaml b/frontend/desktop/data/config.yaml index e19c48611..6cf20f8af 100644 --- a/frontend/desktop/data/config.yaml +++ b/frontend/desktop/data/config.yaml @@ -4,6 +4,8 @@ cloud: regionUID: "thisiaregionuid" certSecretName: "wildcard-cert" common: + enterpriseSupportingMaterials: "" + enterpriseRealNameAuthEnabled: false realNameAuthEnabled: false guideEnabled: false apiEnabled: false @@ -67,3 +69,10 @@ desktop: # authURL: "{{ .oauth2AuthURL }}" # tokenURL: "{{ .oauth2TokenURL }}" # userInfoURL: "{{ .oauth2UserInfoURL }}" +realNameOSS: + accessKey: "" + accessKeySecret: "" + endpoint: "" + realNameBucket: "" + enterpriseRealNameBucket: "" + ssl: true diff --git a/frontend/desktop/package.json b/frontend/desktop/package.json index 91603f41f..7a7be9f5a 100644 --- a/frontend/desktop/package.json +++ b/frontend/desktop/package.json @@ -42,6 +42,7 @@ "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", @@ -65,6 +66,7 @@ "react-contexify": "^6.0.0", "react-dom": "18.2.0", "react-draggable": "^4.4.6", + "react-dropzone": "^14.2.3", "react-hook-form": "^7.46.2", "react-i18next": "^14.1.2", "sass": "^1.68.0", @@ -79,6 +81,7 @@ "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/prisma/global/migrations/20240928050904_add_enterprise_real_name_info_table/migration.sql b/frontend/desktop/prisma/global/migrations/20240928050904_add_enterprise_real_name_info_table/migration.sql new file mode 100644 index 000000000..4d5e5d8b4 --- /dev/null +++ b/frontend/desktop/prisma/global/migrations/20240928050904_add_enterprise_real_name_info_table/migration.sql @@ -0,0 +1,19 @@ +-- CreateTable +CREATE TABLE "EnterpriseRealNameInfo" ( + "id" UUID NOT NULL DEFAULT gen_random_uuid(), + "userUid" UUID NOT NULL, + "enterpriseName" STRING, + "enterpriseQualification" STRING, + "legalRepresentativePhone" STRING, + "isVerified" BOOL NOT NULL DEFAULT false, + "verificationStatus" STRING, + "createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMPTZ(3) NOT NULL, + "additionalInfo" JSONB, + "supportingMaterials" JSONB, + + CONSTRAINT "EnterpriseRealNameInfo_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "EnterpriseRealNameInfo_userUid_key" ON "EnterpriseRealNameInfo"("userUid"); diff --git a/frontend/desktop/prisma/global/schema.prisma b/frontend/desktop/prisma/global/schema.prisma index 1cc93be5e..00526b0c2 100644 --- a/frontend/desktop/prisma/global/schema.prisma +++ b/frontend/desktop/prisma/global/schema.prisma @@ -244,25 +244,41 @@ model UserRealNameInfo { @@map("UserRealNameInfo") } +model EnterpriseRealNameInfo { + id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid + userUid String @unique @db.Uuid + enterpriseName String? + enterpriseQualification String? + legalRepresentativePhone String? + isVerified Boolean @default(false) + verificationStatus String? + createdAt DateTime @default(now()) @db.Timestamptz(3) + updatedAt DateTime @updatedAt @db.Timestamptz(3) + additionalInfo Json? + supportingMaterials Json? + + @@map("EnterpriseRealNameInfo") +} + model RestrictedUser { - id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid - userUid String @unique @db.Uuid - restrictedLevel Int - createdAt DateTime @default(now()) @db.Timestamptz(3) - updatedAt DateTime @updatedAt @db.Timestamptz(3) - additionalInfo Json? + id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid + userUid String @unique @db.Uuid + restrictedLevel Int + createdAt DateTime @default(now()) @db.Timestamptz(3) + updatedAt DateTime @updatedAt @db.Timestamptz(3) + additionalInfo Json? @@map("RestrictedUser") } model RealNameAuthProvider { - id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid - backend String - authType String + id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid + backend String + authType String maxFailedTimes Int - config Json? - createdAt DateTime @default(now()) @db.Timestamptz(3) - updatedAt DateTime @updatedAt @db.Timestamptz(3) + config Json? + createdAt DateTime @default(now()) @db.Timestamptz(3) + updatedAt DateTime @updatedAt @db.Timestamptz(3) @@map("RealNameAuthProvider") } diff --git a/frontend/desktop/public/locales/en/common.json b/frontend/desktop/public/locales/en/common.json index be27e8ac0..ce44ac436 100644 --- a/frontend/desktop/public/locales/en/common.json +++ b/frontend/desktop/public/locales/en/common.json @@ -14,6 +14,7 @@ "and": "and", "app_info": "App Info", "app_launchpad": "App Launchpad", + "attachment": "appendix", "application_desktop": "Application desktop", "application_desktop_tips": "Installed application portal", "avatar": "Avatar", @@ -22,6 +23,7 @@ "bind_success": "Binding successful", "bonus": "Bonus", "bound": "Bound", + "business_license": "operating license", "cancel": "Cancel", "change": "Change", "change_binding": "Change Binding", @@ -31,6 +33,7 @@ "charge": "Charge", "click_anywhere_to_continue": "Click on any blank space to continue", "click_on_any_shadow_to_skip": "Click on any shadow to skip", + "click_to_upload_file": "Click to upload file", "completed": "Finish", "completed_the_deployment_of_an_nginx_for_the_first_time": "Completed the deployment of an nginx for the first time", "confirm": "Confirm", @@ -64,9 +67,15 @@ "emailchangesuccess": "Email modified successfully", "enter": "Enter", "enter_confirm": "Please enter {{value}} to confirm", + "enterprise_name": "Company name", + "enterprise_name_required": "Please enter the correct company name", + "enterprise_verification": "Enterprise real name", "expected_to_use_next_month": "Expected to use next month", "expected_used": "Expected used", + "face_recognition_failed": "Personal real name failed", + "face_recognition_success": "Personal real-name success", "failed_to_generate_invitation_link": "Failed to generate invitation link", + "failed_to_get_qr_code": "Failed to obtain real name QR code", "flow": "Traffic", "force_delete_keywords": "All resources cannot be recovered after account deletion.", "force_delete_tips": "There are still undeleted resources in your account. Once deleted, all resources will be unrecoverable. Please ensure you have backed up or transferred all important data.", @@ -86,8 +95,6 @@ "hello_welcome": "Hello, welcome to", "help_you_enable_high_availability_database": "Help you enable high availability database", "home": "home", - "idCard": "ID CARD", - "idCard_invalid": "INVAILD ID CARD FORMAT", "in_payment": "In Payment ...", "in_time": "In Time", "insufficient_balance": "Your account currently has outstanding payments", @@ -130,7 +137,6 @@ "more_apps": "More Apps", "name": "Name", "name_of_team": "Name of Workspace", - "name_required": "The name format is wrong", "new_email": "New email", "new_phone": "New mobile number", "newpassword": "New Password", @@ -157,17 +163,16 @@ "payment_result": "Payment Result", "payment_status": "Payment Status", "payment_successful": "Payment Successful", + "personal_verification": "Personal real name", "phone": "Phone", - "phone_invalid": "Invalid phone number format", "phone_number_tips": "Phone Number", "phonechangesuccess": "Mobile phone number modified successfully", - "placeholders_idCard": "Please enter your ID card number", - "placeholders_name": "Please enter your name", - "placeholders_phone": "Please enter your phone number", - "placeholders_verifycode": "please enter verification code", "please_enter": "Please enter", "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_supporting_materials": "Please sign and seal near the download, and upload a photo", "privacy_policy": "Privacy Policy", "private_team_id_of_user": "User's ID", "purchase_history": "Purchase History", @@ -180,11 +185,9 @@ "read_and_agree": "Please read and agree to the agreement below", "realNameVerification": "Real Name Verification", "realName_verification": "Real Name Verification", - "realname_auth_failed_tips": "The mobile number has been occupied by another account. Please unbind the mobile number or merge the accounts first.", "realname_auth_now": "Click to verify your name", "realname_auth_reminder": "Real-name authentication reminder", "realname_auth_reminder_desc": "Domestic availability zones require real-name authentication, and use without real-name authentication will be restricted.", - "realname_auth_success": "Identity verification success", "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_info": "RealName", @@ -203,6 +206,8 @@ "remove": "Remove", "remove_member_tips": "Determine that you want to remove the member?", "rename": "Rename", + "retry_get_qr_code": "reacquire", + "scan_qr_code_for_face_recognition": "Use WeChat on your mobile phone to scan the QR code to complete identity verification", "scan_with_wechat": "Scan with WeChat", "sealos_copilot": "Sealos Copilot", "sealos_document": "Sealos Document", @@ -217,6 +222,7 @@ "status": "Status", "storage": "Storage", "submit_error": "Submit Error", + "supporting_materials": "Proof material", "switching_disc": "Switching Disc", "team": "Workspace", "terminal": "Terminal", @@ -228,6 +234,7 @@ "unbound": "Unbound", "under_active_development": "Under active development 🚧", "unread": "Unread", + "upload_success": "Upload successful", "used_last_month": "Used last month", "used_resources": "Used Resources", "user_name": "User Name", @@ -242,13 +249,13 @@ "task_launchpad_title": "Deploy App" }, "verification_code_login": "with Phone", - "verifyCode_invalid": "Verification code format is incorrect", "verify_code_tips": "6-digit Verification Code", "verify_password": "Verify password", "verifycode": "verification", "view_discount_rules": "View recharge discount rules.", "view_later": "Talk to You later", "waiting": "Waiting", + "waiting_for_face_recognition": "Real name result query in progress...", "warning": "Warning", "wechat": "Wechat", "work_order": "Work Order", @@ -258,4 +265,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 8f4443589..cb7c1cb74 100644 --- a/frontend/desktop/public/locales/zh/common.json +++ b/frontend/desktop/public/locales/zh/common.json @@ -13,6 +13,7 @@ "amount_forecast": "根据近一天消耗金额进行预测", "and": "和", "app_info": "应用信息", + "attachment": "附件", "application_desktop": "应用桌面", "application_desktop_tips": "已安装应用入口", "avatar": "头像", @@ -21,6 +22,7 @@ "bind_success": "绑定成功", "bonus": "赠", "bound": "已绑定", + "business_license": "营运执照", "cancel": "取消", "change": "变更", "change_binding": "改绑", @@ -30,6 +32,7 @@ "charge": "充值", "click_anywhere_to_continue": "点击任意空白继续", "click_on_any_shadow_to_skip": "点击任意阴影跳过", + "click_to_upload_file": "点击上传文件", "completed": "完成", "completed_the_deployment_of_an_nginx_for_the_first_time": "部署一个 nginx ,首次完成 将", "confirm": "确认", @@ -61,9 +64,15 @@ "emailchangesuccess": "电子邮箱修改成功", "enter": "输入", "enter_confirm": "请输入 {{value}} 确认", + "enterprise_name": "企业名称", + "enterprise_name_required": "请输入正确的企业名字", + "enterprise_verification": "企业实名", "expected_to_use_next_month": "下月预计使用", "expected_used": "预计还能使用", + "face_recognition_failed": "个人实名失败", + "face_recognition_success": "个人实名成功", "failed_to_generate_invitation_link": "生成邀请链接失败", + "failed_to_get_qr_code": "获取实名二维码失败", "flow": "流量", "force_delete_keywords": "注销后所有资源无法恢复", "force_delete_tips": "您的账号中仍有未删除的资源。一旦注销,所有资源将无法恢复。请确保已经备份或转移了所有重要数据。", @@ -83,8 +92,6 @@ "hello_welcome": "您好, 欢迎来到", "help_you_enable_high_availability_database": "帮您启用高可用数据库", "home": "首页", - "idCard": "身份证", - "idCard_invalid": "身份证格式不对", "in_payment": "支付中 ...", "in_time": "加入时间", "insufficient_balance": "您的账户目前存在未结清的款项", @@ -126,7 +133,6 @@ "more_apps": "更多应用", "name": "姓名", "name_of_team": "工作空间名称", - "name_required": "姓名格式不对", "new_email": "新电子邮箱", "new_phone": "新手机号", "newpassword": "新密码", @@ -153,17 +159,17 @@ "payment_result": "支付结果", "payment_status": "支付状态", "payment_successful": "支付成功", + "personal_verification": "个人实名", "phone": "手机号", - "phone_invalid": "电话号码格式不正确", "phone_number_tips": "手机号", "phonechangesuccess": "手机号修改成功", - "placeholders_idCard": "请输入您的身份证号码", - "placeholders_name": "请输入您的姓名", - "placeholders_phone": "请输入您的电话号码", - "placeholders_verifycode": "请输入验证码", "please_enter": "请输入", "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": "请在下载的附近上签名并盖好公章,上传照片", "privacy_policy": "隐私政策", "private_team_id_of_user": "用户ID", "purchase_history": "购买记录", @@ -176,11 +182,9 @@ "read_and_agree": "请阅读并同意下方协议", "realNameVerification": "实名认证", "realName_verification": "实名认证", - "realname_auth_failed_tips": "手机号已被其他账号占用,请先解绑该手机号,或者完成账号合并操作。", "realname_auth_now": "点击进行实名", "realname_auth_reminder": "实名认证提醒", "realname_auth_reminder_desc": "国内可用区需要实名认证,未实名认证将会被限制使用,", - "realname_auth_success": "实名认证成功", "realname_auth_tips_a": "1、请确保所填写的姓名、身份证号码和手机号码信息一致。", "realname_auth_tips_b": "2、部分虚拟运营商提供的号段可能无法验证通过,请申请工单通过人工协助认证。", "realname_info": "实名信息", @@ -199,6 +203,8 @@ "remove": "移除", "remove_member_tips": "确认要移除该成员?", "rename": "重命名", + "retry_get_qr_code": "重新获取", + "scan_qr_code_for_face_recognition": "使用手机微信扫描二维码,完成身份验证", "scan_with_wechat": "微信扫码支付", "sealos_copilot": "Sealos 小助理", "sealos_newcomer_benefits": "Sealos 新手福利", @@ -212,6 +218,7 @@ "status": "状态", "storage": "存储", "submit_error": "提交错误", + "supporting_materials": "证明材料", "switching_disc": "切换圆盘", "team": "工作空间", "the_invited_user_must_be_others": "只能邀请其他人", @@ -221,6 +228,7 @@ "unbound": "未绑定", "under_active_development": "正在积极开发中 🚧", "unread": "未读", + "upload_success": "上传成功", "used_last_month": "上月已使用", "used_resources": "已用资源", "user_name": "用户名", @@ -235,13 +243,13 @@ "task_appstore_title": "应用商店" }, "verification_code_login": "手机号登录", - "verifyCode_invalid": "验证码格式不对", "verify_code_tips": "6位验证码", "verify_password": "确认密码", "verifycode": "验证码", "view_discount_rules": "查看优惠规则", "view_later": "稍后再说", "waiting": "等待中", + "waiting_for_face_recognition": "实名结果查询中...", "warning": "警告", "wechat": "微信", "work_order": "工单", @@ -251,4 +259,4 @@ "you_can_view_fees_through_the_fee_center": "您可通过费用中心查看费用", "you_have_not_purchased_the_license": "您还没有购买 License", "yuan": "元" -} \ No newline at end of file +} diff --git a/frontend/desktop/src/api/auth.ts b/frontend/desktop/src/api/auth.ts index 0819d115c..33ee7e991 100644 --- a/frontend/desktop/src/api/auth.ts +++ b/frontend/desktop/src/api/auth.ts @@ -58,6 +58,8 @@ export const _UserInfo = (request: AxiosInstance) => () => ApiResp<{ info: { realName?: string; + enterpriseVerificationStatus?: string; + enterpriseRealName?: string; userRestrictedLevel?: number; uid: string; createdAt: Date; @@ -158,9 +160,29 @@ export const _checkRemainResource = (request: AxiosInstance) => () => export const _forceDeleteUser = (request: AxiosInstance) => (data: { code: string }) => request.post>('/api/auth/delete/force', data); -export const _realNameAuthRequest = - (request: AxiosInstance) => (data: { name: string; phone?: string; idCard: string }) => - request.post>('/api/account/realNameAuth', data); + +export const _faceAuthGenerateQRcodeUriRequest = (request: AxiosInstance) => () => + request.get>( + '/api/account/generateRealNameQRcodeUri' + ); + +export const _getFaceAuthStatusRequest = (request: AxiosInstance) => (data: { bizToken: string }) => + request.post>( + '/api/account/getFaceAuthStatus', + data + ); + +export const _enterpriseRealNameAuthRequest = (request: AxiosInstance) => (data: FormData) => { + return request.post>( + '/api/account/enterpriseRealNameAuth', + data, + { + headers: { + 'Content-Type': 'multipart/form-data' + } + } + ); +}; export const _getAmount = (request: AxiosInstance) => () => request>('/api/account/getAmount'); @@ -189,6 +211,8 @@ export const deleteUserRequest = _deleteUser(request); export const checkRemainResource = _checkRemainResource(request); export const forceDeleteUser = _forceDeleteUser(request); -export const realNameAuthRequest = _realNameAuthRequest(request); +export const enterpriseRealNameAuthRequest = _enterpriseRealNameAuthRequest(request); +export const faceAuthGenerateQRcodeUriRequest = _faceAuthGenerateQRcodeUriRequest(request); +export const getFaceAuthStatusRequest = _getFaceAuthStatusRequest(request); export const getAmount = _getAmount(request); diff --git a/frontend/desktop/src/components/account/AccountCenter/index.tsx b/frontend/desktop/src/components/account/AccountCenter/index.tsx index 81ad85088..43a29c05a 100644 --- a/frontend/desktop/src/components/account/AccountCenter/index.tsx +++ b/frontend/desktop/src/components/account/AccountCenter/index.tsx @@ -222,8 +222,18 @@ export default function Index(props: Omit) { {t('common:realname_info')}} RightElement={ + infoData.data.enterpriseVerificationStatus === 'Success' || infoData.data.realName ? ( - {infoData?.data.realName} + + + {infoData?.data.enterpriseRealName || infoData?.data.realName} + + ) : ( void; - } -): ReactElement { - const { message } = useMessage(); - const { t } = useTranslation(); - const { setSessionProp } = useSessionStore(); - const { session } = useSessionStore((s) => s); - const [phoneNumber, setPhoneNumber] = useState(null); - - const queryClient = useQueryClient(); - const infoData = useQuery({ - queryFn: UserInfo, - queryKey: [session?.token, 'UserInfo'], - select(d) { - return d.data?.info; - } - }); - - const { seconds, startTimer, isRunning } = useTimer({ - duration: 60, - step: 1 - }); - const remainTime = 60 - seconds; - - const schema = z.object({ - name: z - .string() - .min(1, { message: t('common:name_required') }) - .max(20, { message: t('common:name_required') }), - phone: z - .string() - .min(1, { message: t('common:phone_invalid') }) - .regex(/^\d+$/, { message: t('common:phone_invalid') }) - .max(16, { message: t('common:phone_invalid') }), - idCard: z.string().refine(identityCodeValid, { message: t('common:idCard_invalid') }), - verifyCode: z - .string() - .optional() - .refine((val) => (!phoneNumber ? /^\d{6}$/.test(val || '') : true), { - message: t('common:verifyCode_invalid') - }) - }); - - type FormData = z.infer; - - const { - register, - handleSubmit, - reset, - trigger, - getValues, - setValue, - formState: { errors } - } = useForm({ - resolver: zodResolver(schema), - mode: 'onChange' - }); - - useEffect(() => { - if (infoData.isSuccess && infoData.data) { - const phoneProvider = infoData.data.oauthProvider.find((p) => p.providerType === 'PHONE'); - const phoneNumber = phoneProvider?.providerId || null; - setPhoneNumber(phoneNumber); - if (phoneNumber) { - setValue('phone', phoneNumber, { shouldValidate: true }); - } - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [infoData.isSuccess, infoData.data]); - - const getCodeMutation = useMutation({ - mutationFn({ id, smsType }: { id: string; smsType: SmsType }) { - return getSmsBindCodeRequest(smsType)({ id }); - }, - onSuccess(data) { - startTimer(); - message({ - status: 'success', - title: t('common:already_sent_code'), - position: 'top', - duration: 2000, - isClosable: true - }); - }, - onError(err) { - getCodeMutation.reset(); - message({ - status: 'error', - title: t('common:get_code_failed'), - position: 'top', - duration: 2000, - isClosable: true - }); - } - }); - - const getCode: MouseEventHandler = async (e) => { - e.preventDefault(); - if (isRunning) { - message({ - status: 'warning', - title: t('common:already_sent_code'), - position: 'top', - duration: 2000, - isClosable: true - }); - return; - } - if (!(await trigger('phone'))) { - message({ - status: 'error', - title: t('common:invalid_phone_number'), - position: 'top', - duration: 2000, - isClosable: true - }); - return; - } - const id = getValues('phone'); - getCodeMutation.mutate({ - id, - smsType: 'phone' - }); - }; - - const verifyCodeAndSmsBindMutation = useMutation({ - async mutationFn({ smsType, ...data }: { id: string; code: string; smsType: SmsType }) { - return verifySmsBindRequest('phone')(data); - } - }); - - const realNameAuthMutation = useMutation(realNameAuthRequest, { - onSuccess: (data) => { - if (data.code === 200) { - message({ - title: t('common:realname_auth_success'), - status: 'success', - duration: 2000, - isClosable: true - }); - - setSessionProp('user', { - ...useSessionStore.getState().session!.user!, - realName: data.data?.name - }); - - queryClient.invalidateQueries([session?.token, 'UserInfo']); - - reset(); - - if (props.onFormSuccess && typeof props.onFormSuccess === 'function') { - props.onFormSuccess(); - } - } else { - message({ - title: data.message, - status: 'error', - position: 'top', - duration: 2000, - isClosable: true - }); - } - }, - onError: (error: Error) => { - message({ - title: error.message, - status: 'error', - position: 'top', - duration: 2000, - isClosable: true - }); - } - }); - - const onValidate = async (data: FormData) => { - if (!phoneNumber) { - try { - const verifyResult = await verifyCodeAndSmsBindMutation.mutateAsync({ - id: data.phone, - code: data.verifyCode!, - smsType: 'phone' - }); - - if (verifyResult.code !== 200) { - message({ - title: t('common:realname_auth_failed_tips'), - status: 'error', - position: 'top', - duration: 2000, - isClosable: true - }); - - return; - } - } catch (error) { - message({ - title: (error as Error).message, - status: 'error', - position: 'top', - duration: 2000, - isClosable: true - }); - return; - } - } - - realNameAuthMutation.mutate({ - name: data.name, - idCard: data.idCard - }); - }; - - 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); - - return ( - <> - {infoData.isSuccess && infoData.data ? ( - - {/* Notification area */} - - {t('common:realname_auth_tips_a')} - {t('common:realname_auth_tips_b')} - - - {/* Form area */} -
- - - - - {t('common:name')} - - - - {errors.name && {errors.name.message}} - - - - - - {t('common:phone')} - - - - {!phoneNumber && ( - - - {t('common:get_code')} - - - )} - - - {errors.phone && {errors.phone.message}} - - - {!phoneNumber && ( - - - - {t('common:verifycode')} - - - - - {isRunning && {remainTime} s} - - - - {errors.verifyCode && ( - {errors.verifyCode.message} - )} - - )} - - - - - {t('common:idCard')} - - - - {errors.idCard && {errors.idCard.message}} - - - - -
-
- ) : ( -
- -
- )} - - ); -} - function RealNameModal(props: { children: React.ReactElement; onModalOpen?: () => void; @@ -574,18 +183,700 @@ function RealNameModal(props: { {t('common:realName_verification')} - { - onClose(); - if (props.onFormSuccess && typeof props.onFormSuccess === 'function') { - props.onFormSuccess(); - } - }} - /> + + + + {t('common:personal_verification')} + + + {t('common:enterprise_verification')} + + + + + + { + onClose(); + if (props.onFormSuccess && typeof props.onFormSuccess === 'function') { + props.onFormSuccess(); + } + }} + /> + + + { + onClose(); + if (props.onFormSuccess && typeof props.onFormSuccess === 'function') { + props.onFormSuccess(); + } + }} + /> + + + ); } + +export function RealNameAuthForm( + props: FlexProps & { + onFormSuccess?: () => void; + } +) { + const { t } = useTranslation(); + return ( + + + + {t('common:personal_verification')} + + + {t('common:enterprise_verification')} + + + + + + { + if (props.onFormSuccess && typeof props.onFormSuccess === 'function') { + props.onFormSuccess(); + } + }} + /> + + + { + if (props.onFormSuccess && typeof props.onFormSuccess === 'function') { + props.onFormSuccess(); + } + }} + /> + + + + ); +} + +export function FaceIdRealNameAuthORcode( + props: FlexProps & { + onFormSuccess?: () => void; + } +) { + const { t } = useTranslation(); + const { message } = useMessage(); + const queryClient = useQueryClient(); + const [isPolling, setIsPolling] = useState(false); + const { session } = useSessionStore((s) => s); + const { setSessionProp } = useSessionStore(); + const [refetchCount, setRefetchCount] = useState(0); + + const { data, isLoading, error, refetch } = useQuery( + ['faceIdAuth'], + faceAuthGenerateQRcodeUriRequest, + { + retry: false, + refetchOnWindowFocus: false + } + ); + + const handleRefetch = useCallback(() => { + setRefetchCount((prev) => prev + 1); + refetch(); + }, [refetch]); + + useEffect(() => { + let intervalId: NodeJS.Timeout; + + const bizToken = data?.data?.bizToken; + + if (!bizToken) { + return; + } + + const stopPolling = () => { + if (intervalId) clearInterval(intervalId); + setIsPolling(false); + }; + + const startPolling = () => { + if (!isPolling) { + setIsPolling(true); + intervalId = setInterval(async () => { + try { + const result = await getFaceAuthStatusRequest({ bizToken }); + if (result.data?.status === 'Success') { + message({ + title: t('common:face_recognition_success'), + status: 'success', + duration: 2000, + isClosable: true + }); + + setSessionProp('user', { + ...useSessionStore.getState().session!.user!, + realName: result.data?.realName + }); + + queryClient.invalidateQueries([session?.token, 'UserInfo']); + + stopPolling(); + + if (props.onFormSuccess && typeof props.onFormSuccess === 'function') { + props.onFormSuccess(); + } + } + if (result.data?.status === 'Failed') { + message({ + title: t('common:face_recognition_failed'), + status: 'error', + duration: 2000, + isClosable: true + }); + + stopPolling(); + handleRefetch(); + } + } catch (error: any) { + console.error('Error checking face ID auth status:', error); + message({ + title: error.message, + status: 'error', + duration: 2000, + isClosable: true + }); + } + }, 2000); + } + }; + + startPolling(); + + return stopPolling; + }, [session?.token, data, data?.data?.bizToken, refetchCount]); + + if (error) { + return ( + + {t('common:failed_to_get_qr_code')} + + + + + ); + } + + if (isLoading) { + return ( +
+ + {t('common:loading')} +
+ ); + } + + return ( + + {data?.data?.url && ( + <> + + + {t('common:scan_qr_code_for_face_recognition')} + +
+ +
+ {isPolling && ( + + {t('common:waiting_for_face_recognition')} + + )} +
+ + )} +
+ ); +} + +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 { message } = useMessage(); + + const { session } = useSessionStore((s) => s); + const { setSessionProp } = useSessionStore(); + + const queryClient = useQueryClient(); + + const schema = z.object({ + enterpriseName: 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() + }); + + type FormData = z.infer; + + const { + register, + handleSubmit, + setValue, + watch, + reset, + formState: { errors } + } = useForm({ + resolver: zodResolver(schema), + defaultValues: { + enterpriseName: '', + enterpriseQualification: null, + supportingMaterials: null + } + }); + + 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, { + onSuccess: (data) => { + if (data.code === 200) { + message({ + title: t('common:upload_success'), + status: 'success', + duration: 2000, + isClosable: true, + position: 'top' + }); + + queryClient.invalidateQueries([session?.token, 'UserInfo']); + setSessionProp('user', { + ...useSessionStore.getState().session!.user!, + enterpriseVerificationStatus: data.data?.status + }); + + reset(); + + if (props.onFormSuccess && typeof props.onFormSuccess === 'function') { + props.onFormSuccess(); + } + } else { + message({ + title: data.message, + status: 'error', + position: 'top', + duration: 2000, + isClosable: true + }); + } + }, + onError: (error: Error) => { + message({ + title: error.message, + status: 'error', + position: 'top', + duration: 2000, + isClosable: true + }); + } + }); + + const onValidate = async (data: { + enterpriseName: string; + enterpriseQualification: File | null; + supportingMaterials: File | null; + }) => { + if (!data.enterpriseQualification || !data.supportingMaterials) { + message({ + title: t('common:please_fill_all_fields'), + status: 'warning', + position: 'top', + duration: 2000, + isClosable: true + }); + + return; + } + const formData = new FormData(); + formData.append('enterpriseName', data.enterpriseName); + formData.append('enterpriseQualification', data.enterpriseQualification); + formData.append('supportingMaterials', data.supportingMaterials); + enterpriseRealNameAuthMutation.mutate(formData); + }; + + 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); + + return ( +
+ + + + + {t('common:enterprise_name')} + + + + {errors.enterpriseName && ( + {errors.enterpriseName.message} + )} + + + + + + + + + + + + +
+ ); +} + export default RealNameModal; diff --git a/frontend/desktop/src/components/desktop_content/index.tsx b/frontend/desktop/src/components/desktop_content/index.tsx index 55dad633f..41e2c06bd 100644 --- a/frontend/desktop/src/components/desktop_content/index.tsx +++ b/frontend/desktop/src/components/desktop_content/index.tsx @@ -125,11 +125,13 @@ export default function Desktop(props: any) { }, [openDesktopApp]); useEffect(() => { - if (infoData.isSuccess && !infoData?.data?.realName && commonConfig?.realNameAuthEnabled) { - realNameAuthNotificationIdRef.current = realNameAuthNotification({ - duration: null, - isClosable: true - }); + if (infoData.isSuccess && commonConfig?.realNameAuthEnabled) { + if (!infoData?.data?.realName && infoData?.data?.enterpriseVerificationStatus !== 'Success') { + realNameAuthNotificationIdRef.current = realNameAuthNotification({ + duration: null, + isClosable: true + }); + } } return () => { diff --git a/frontend/desktop/src/components/icons/index.tsx b/frontend/desktop/src/components/icons/index.tsx index c4ba436bc..ca1799653 100644 --- a/frontend/desktop/src/components/icons/index.tsx +++ b/frontend/desktop/src/components/icons/index.tsx @@ -560,3 +560,87 @@ export function RightArrowIcon(props: IconProps) { ); } + +export function UploadIcon(props: IconProps) { + return ( + + + + ); +} + +export function PictureIcon(props: IconProps) { + return ( + + + + + ); +} + +export function DeleteIcon(props: IconProps) { + return ( + + + + ); +} + +export function AttachmentIcon(props: IconProps) { + return ( + + + + ); +} diff --git a/frontend/desktop/src/components/signin/auth/usePassword.tsx b/frontend/desktop/src/components/signin/auth/usePassword.tsx index 7c5324989..be8ed7cec 100644 --- a/frontend/desktop/src/components/signin/auth/usePassword.tsx +++ b/frontend/desktop/src/components/signin/auth/usePassword.tsx @@ -95,6 +95,9 @@ 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 }, kubeconfig: regionResult.data.kubeconfig diff --git a/frontend/desktop/src/components/signin/auth/useWechat.tsx b/frontend/desktop/src/components/signin/auth/useWechat.tsx index d02500489..ea2084389 100644 --- a/frontend/desktop/src/components/signin/auth/useWechat.tsx +++ b/frontend/desktop/src/components/signin/auth/useWechat.tsx @@ -53,7 +53,10 @@ export default function useWechat() { userUid: payload.userUid, userId: payload.userId, realName: infoData.data?.info.realName || undefined, - userRestrictedLevel: infoData.data?.info.userRestrictedLevel || undefined + userRestrictedLevel: infoData.data?.info.userRestrictedLevel || undefined, + enterpriseVerificationStatus: + infoData.data?.info.enterpriseVerificationStatus || undefined, + enterpriseRealName: infoData.data?.info.enterpriseRealName || undefined }, // @ts-ignore kubeconfig: result.data.kubeconfig diff --git a/frontend/desktop/src/pages/api/account/enterpriseRealNameAuth.ts b/frontend/desktop/src/pages/api/account/enterpriseRealNameAuth.ts new file mode 100644 index 000000000..dcbb683ad --- /dev/null +++ b/frontend/desktop/src/pages/api/account/enterpriseRealNameAuth.ts @@ -0,0 +1,245 @@ +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/faceIdRealNameAuthCallback.ts b/frontend/desktop/src/pages/api/account/faceIdRealNameAuthCallback.ts new file mode 100644 index 000000000..c265dd639 --- /dev/null +++ b/frontend/desktop/src/pages/api/account/faceIdRealNameAuthCallback.ts @@ -0,0 +1,308 @@ +import { verifyAccessToken } from '@/services/backend/auth'; +import { jsonRes } from '@/services/backend/response'; +import { enableRealNameAuth } from '@/services/enable'; +import * as tcsdk from 'tencentcloud-sdk-nodejs'; +import { NextApiRequest, NextApiResponse } from 'next'; +import { globalPrisma } from '@/services/backend/db/init'; +import { GetDetectInfoEnhancedResponse } from 'tencentcloud-sdk-nodejs/tencentcloud/services/faceid/v20180301/faceid_models'; +import { RealNameOSSConfigType } from '@/types'; +import { Client, ClientOptions } from 'minio'; + +type TencentCloudFaceAuthConfig = { + secretId: string; + secretKey: string; + ruleId: string; +}; + +type JsonValue = string | number | boolean | object | null; + +type RealNameAuthProvider = { + id: string; + backend: string; + authType: string; + maxFailedTimes: number; + config: JsonValue; + createdAt: Date; + updatedAt: Date; +}; + +type AdditionalInfo = + | { + faceRecognition?: { + callback?: { + bizToken?: string; + url?: string | null; + isUsed?: boolean; + createdAt?: number; + }; + }; + userMaterials?: string[]; + } + | any; + +const realNameOSS: RealNameOSSConfigType = global.AppConfig.realNameOSS; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + if (!enableRealNameAuth) { + console.error('faceidRealNameAuth: Real name authentication not enabled'); + return jsonRes(res, { code: 503, message: 'Real name authentication not enabled' }); + } + + const bizToken = req.query?.BizToken as string; + const extraQuery = req.query?.Extra as string; + + const regionToken = extraQuery?.split('regionToken=')[1]; + if (!regionToken) { + return jsonRes(res, { code: 400, message: 'Token is required' }); + } + + req.headers['authorization'] = regionToken; + + const payload = await verifyAccessToken(req.headers); + if (!payload) return jsonRes(res, { code: 401, message: 'Token is invaild' }); + + if (!realNameOSS) { + return jsonRes(res, { + code: 500, + message: 'Real name authentication oss configuration not found' + }); + } + + try { + const realNameAuthProvider: RealNameAuthProvider | null = + await globalPrisma.realNameAuthProvider.findFirst({ + where: { + backend: 'TENCENTCLOUD', + authType: 'tcloudFaceAuth' + } + }); + + if (!realNameAuthProvider) { + throw new Error('faceidRealNameAuth: Real name authentication provider not found'); + } + + const config: TencentCloudFaceAuthConfig = + realNameAuthProvider.config as TencentCloudFaceAuthConfig; + + if (!config) { + throw new Error('faceidRealNameAuth: Real name authentication configuration not found'); + } + + const userRealNameFaceAuthInfo = await getUserRealNameInfo(bizToken, config); + const isFaceRecognitionSuccess = userRealNameFaceAuthInfo.Text?.ErrCode === 0; + + const userUid = payload.userUid; + const timestamp = Date.now(); + + // Fetch existing user real name info + const userRealNameInfo = await globalPrisma.userRealNameInfo.findUnique({ + where: { userUid } + }); + + if (!userRealNameInfo || !userRealNameInfo.additionalInfo) { + return jsonRes(res, { code: 400, message: 'User real name info not found' }); + } + + let additionalInfo: AdditionalInfo = userRealNameInfo.additionalInfo; + + additionalInfo.faceRecognition.callback.isUsed = true; + + // Initialize or reset userMaterials array + additionalInfo.userMaterials = []; + + const minioConfig: ClientOptions = { + endPoint: realNameOSS.endpoint, + accessKey: realNameOSS.accessKey, + secretKey: realNameOSS.accessKeySecret, + useSSL: realNameOSS.ssl + }; + const minioClient = new Client(minioConfig); + + if (userRealNameFaceAuthInfo.BestFrame?.BestFrame) { + const imageBuffer = Buffer.from(userRealNameFaceAuthInfo.BestFrame.BestFrame, 'base64'); + const imagePath = `${userUid}/${timestamp}_bestframe.jpg`; + await uploadFile( + minioClient, + realNameOSS.realNameBucket, + imagePath, + imageBuffer, + 'image/jpeg' + ); + additionalInfo.userMaterials.push(imagePath); + } + + if (userRealNameFaceAuthInfo.VideoData?.LivenessVideo) { + const videoBuffer = Buffer.from(userRealNameFaceAuthInfo.VideoData.LivenessVideo, 'base64'); + const videoPath = `${userUid}/${timestamp}_video.mp4`; + await uploadFile( + minioClient, + realNameOSS.realNameBucket, + videoPath, + videoBuffer, + 'video/mp4' + ); + additionalInfo.userMaterials.push(videoPath); + } + + if (isFaceRecognitionSuccess) { + await globalPrisma.userRealNameInfo.update({ + where: { userUid }, + data: { + realName: userRealNameFaceAuthInfo.Text?.Name, + idCard: userRealNameFaceAuthInfo.Text?.IdCard, + isVerified: true, + additionalInfo + } + }); + + res.setHeader('Content-Type', 'text/html'); + return res.send(` + + + + + + Real Name Authentication + + + +

Real Name Authentication Successful

+ + + `); + } else { + await globalPrisma.userRealNameInfo.update({ + where: { userUid }, + data: { + isVerified: false, + idVerifyFailedTimes: { increment: 1 }, + additionalInfo + } + }); + + res.setHeader('Content-Type', 'text/html'); + return res.send(` + + + + + + Real Name Authentication + + + +

Real Name Authentication Failed

+ + + `); + } + } catch (error) { + console.error('faceidRealNameAuth: Internal error'); + console.error(error); + res.setHeader('Content-Type', 'text/html'); + return res.status(500).send(` + + + + + + Server Error + + + +
+

Server Error

+

The server has encountered an error. Please try again later.

+
+ + + `); + } +} + +async function getUserRealNameInfo( + bizToken: string, + config: TencentCloudFaceAuthConfig +): Promise { + const FaceClient = tcsdk.faceid.v20180301.Client; + const client = new FaceClient({ + credential: { + secretId: config.secretId, + secretKey: config.secretKey + }, + region: '', + profile: { + signMethod: 'HmacSHA256', + httpProfile: { + endpoint: 'faceid.tencentcloudapi.com', + reqMethod: 'POST', + reqTimeout: 30 // Request timeout, default 60s + } + } + }); + + const params = { + BizToken: bizToken, + InfoType: '0', + RuleId: config.ruleId, + BestFramesCount: 0 + }; + + const data = await client.GetDetectInfoEnhanced(params); + return data; +} + +async function uploadFile( + minioClient: Client, + bucket: string, + path: string, + buffer: Buffer, + contentType: string +): Promise { + await minioClient.putObject(bucket, path, buffer, buffer.length, { 'Content-Type': contentType }); +} diff --git a/frontend/desktop/src/pages/api/account/generateRealNameQRcodeUri.ts b/frontend/desktop/src/pages/api/account/generateRealNameQRcodeUri.ts new file mode 100644 index 000000000..3120f8a85 --- /dev/null +++ b/frontend/desktop/src/pages/api/account/generateRealNameQRcodeUri.ts @@ -0,0 +1,210 @@ +import { jsonRes } from '@/services/backend/response'; +import { enableRealNameAuth } from '@/services/enable'; +import type { NextApiRequest, NextApiResponse } from 'next'; +import * as tcsdk from 'tencentcloud-sdk-nodejs'; +import { verifyAccessToken } from '@/services/backend/auth'; +import { globalPrisma } from '@/services/backend/db/init'; + +type TencentCloudFaceAuthConfig = { + secretId: string; + secretKey: string; + ruleId: string; +}; + +type JsonValue = string | number | boolean | object | null; + +type RealNameAuthProvider = { + id: string; + backend: string; + authType: string; + maxFailedTimes: number; + config: JsonValue; + createdAt: Date; + updatedAt: Date; +}; + +type AdditionalInfo = + | { + faceRecognition?: { + callback?: { + bizToken?: string; + url?: string | null; + isUsed?: boolean; + createdAt?: number; + }; + }; + userMaterials?: string[]; + } + | any; + +type QRCodeUrlResult = { + url: string; + bizToken: string; +}; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + if (!enableRealNameAuth) { + console.error('faceidRealNameAuth: Real name authentication not enabled'); + return jsonRes(res, { code: 503, message: 'Real name authentication not enabled' }); + } + + if (req.method !== 'GET') { + console.error('faceidRealNameAuth: 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 invaild' }); + + try { + const realNameAuthProvider: RealNameAuthProvider | null = + await globalPrisma.realNameAuthProvider.findFirst({ + where: { + backend: 'TENCENTCLOUD', + authType: 'tcloudFaceAuth' + } + }); + + if (!realNameAuthProvider) { + throw new Error('faceidRealNameAuth: Real name authentication provider not found'); + } + + const config: TencentCloudFaceAuthConfig = + realNameAuthProvider.config as TencentCloudFaceAuthConfig; + + if (!config) { + throw new Error('faceidRealNameAuth: Real name authentication configuration not found'); + } + + const realNameInfo = await globalPrisma.userRealNameInfo.findUnique({ + where: { + userUid: payload.userUid + } + }); + + if (realNameInfo && realNameInfo.isVerified) { + console.info(`faceidRealNameAuth: User ${payload.userUid} has already been verified`); + return jsonRes(res, { + code: 409, + message: 'Identity verification has been completed, cannot be repeated.' + }); + } + + if (realNameInfo && realNameInfo.idVerifyFailedTimes >= realNameAuthProvider.maxFailedTimes) { + console.info( + `faceidRealNameAuth: User ${payload.userUid} has reached the maximum number of failed attempts` + ); + return jsonRes(res, { + code: 429, + message: 'You have exceeded the maximum number of attempts. Please submit a ticket' + }); + } + + let urlResult: QRCodeUrlResult | null = null; + let additionalInfo: AdditionalInfo = realNameInfo?.additionalInfo || {}; + + const currentTime = new Date().getTime(); + const urlCreatedAt = additionalInfo.faceRecognition?.callback?.createdAt || 0; + const urlExpirationTime = 7200 * 1000; + + /* If the user has not been authenticated, or the authentication link has expired, + or the authentication link has already been used, + the authentication link needs to be regenerated. + */ + + const shouldGenerateNewUrl = + !realNameInfo || + !additionalInfo.faceRecognition?.callback?.url || + additionalInfo.faceRecognition?.callback?.isUsed || + currentTime - urlCreatedAt > urlExpirationTime; + + if (shouldGenerateNewUrl) { + const redirectUrl = `https://${global.AppConfig?.cloud.domain}/api/account/faceIdRealNameAuthCallback`; + const regionToken = req.headers['authorization'] as string; + urlResult = await generateRealNameQRcodeUri( + redirectUrl, + regionToken, + config as TencentCloudFaceAuthConfig + ); + + additionalInfo = { + ...additionalInfo, + faceRecognition: { + ...additionalInfo.faceRecognition, + callback: { + bizToken: urlResult.bizToken, + url: urlResult.url, + isUsed: false, + createdAt: currentTime + } + } + }; + + await globalPrisma.userRealNameInfo.upsert({ + where: { userUid: payload.userUid }, + update: { additionalInfo }, + create: { + userUid: payload.userUid, + isVerified: false, + idVerifyFailedTimes: 0, + additionalInfo + } + }); + } else { + urlResult = { + url: additionalInfo.faceRecognition?.callback?.url || null, + bizToken: additionalInfo.faceRecognition?.callback?.bizToken || null + }; + } + + return jsonRes(res, { + code: 200, + message: 'success generate real name auth url', + data: { url: urlResult.url, bizToken: urlResult.bizToken } + }); + } catch (error) { + console.error('faceidRealNameAuth: Internal error'); + console.error(error); + return jsonRes(res, { code: 500, data: 'The server has encountered an error' }); + } +} + +async function generateRealNameQRcodeUri( + redirectUrl: string, + regionToken: string, + config: TencentCloudFaceAuthConfig +): Promise { + const FaceClient = tcsdk.faceid.v20180301.Client; + const client = new FaceClient({ + credential: { + secretId: config.secretId, + secretKey: config.secretKey + }, + region: '', + profile: { + signMethod: 'HmacSHA256', + httpProfile: { + endpoint: 'faceid.tencentcloudapi.com', + reqMethod: 'POST', + reqTimeout: 30 // Request timeout, default 60s + } + } + }); + + const params = { + RuleId: config.ruleId, + RedirectUrl: redirectUrl, + Extra: `regionToken=${regionToken}` + }; + + const data = await client.DetectAuth(params); + + if (!data.Url || !data.BizToken) { + throw new Error('Failed to generate QR code URL: Missing Url or BizToken'); + } + + return { + url: data.Url, + bizToken: data.BizToken + }; +} diff --git a/frontend/desktop/src/pages/api/account/getFaceAuthStatus.ts b/frontend/desktop/src/pages/api/account/getFaceAuthStatus.ts new file mode 100644 index 000000000..2382381e9 --- /dev/null +++ b/frontend/desktop/src/pages/api/account/getFaceAuthStatus.ts @@ -0,0 +1,144 @@ +import { jsonRes } from '@/services/backend/response'; +import { enableRealNameAuth } from '@/services/enable'; +import type { NextApiRequest, NextApiResponse } from 'next'; +import * as tcsdk from 'tencentcloud-sdk-nodejs'; +import { verifyAccessToken } from '@/services/backend/auth'; +import { globalPrisma } from '@/services/backend/db/init'; +import { z } from 'zod'; +import { GetDetectInfoEnhancedResponse } from 'tencentcloud-sdk-nodejs/tencentcloud/services/faceid/v20180301/faceid_models'; + +type TencentCloudFaceAuthConfig = { + secretId: string; + secretKey: string; + ruleId: string; +}; + +type JsonValue = string | number | boolean | object | null; + +type RealNameAuthProvider = { + id: string; + backend: string; + authType: string; + maxFailedTimes: number; + config: JsonValue; + createdAt: Date; + updatedAt: Date; +}; + +enum FaceAuthStatus { + SUCCESS = 'Success', + FAIL = 'Failed', + PENDING = 'Pending' +} + +type FaceAuthResult = { + status: FaceAuthStatus; + realName?: string; +}; + +const bodySchema = z.object({ + bizToken: z.string().length(36, { message: 'bizToken must be exactly 36 characters long' }) +}); + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + if (!enableRealNameAuth) { + console.error('faceidRealNameAuth: Real name authentication not enabled'); + return jsonRes(res, { code: 503, message: 'Real name authentication not enabled' }); + } + + if (req.method !== 'POST') { + console.error('realNameAuth: 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 invaild' }); + + const faceAuthResult: FaceAuthResult = { + status: FaceAuthStatus.PENDING + }; + + try { + const { bizToken } = bodySchema.parse(req.body); + + const realNameAuthProvider: RealNameAuthProvider | null = + await globalPrisma.realNameAuthProvider.findFirst({ + where: { + backend: 'TENCENTCLOUD', + authType: 'tcloudFaceAuth' + } + }); + + if (!realNameAuthProvider) { + throw new Error('faceidRealNameAuth: Real name authentication provider not found'); + } + + const config: TencentCloudFaceAuthConfig = + realNameAuthProvider.config as TencentCloudFaceAuthConfig; + + if (!config) { + throw new Error('faceidRealNameAuth: Real name authentication configuration not found'); + } + + const faceAuthInfo = await getUserRealNameInfo(bizToken, config); + + const realNameInfo = await globalPrisma.userRealNameInfo.findUnique({ + where: { + userUid: payload.userUid + } + }); + + if (faceAuthInfo.Text?.ErrCode !== null && faceAuthInfo.Text?.ErrCode === 0) { + if (realNameInfo && realNameInfo.realName && realNameInfo.isVerified) { + faceAuthResult.status = FaceAuthStatus.SUCCESS; + faceAuthResult.realName = realNameInfo.realName; + } + } + + if (faceAuthInfo.Text?.ErrCode !== null && faceAuthInfo.Text?.ErrCode !== 0) { + faceAuthResult.status = FaceAuthStatus.FAIL; + } + + return jsonRes(res, { + code: 200, + message: 'success get face auth result', + data: { status: faceAuthResult.status, realName: faceAuthResult.realName } + }); + } catch (error) { + console.error('faceidRealNameAuth: Internal error'); + console.error(error); + return jsonRes(res, { code: 500, data: 'The server has encountered an error' }); + } +} + +async function getUserRealNameInfo( + bizToken: string, + config: TencentCloudFaceAuthConfig +): Promise { + const FaceClient = tcsdk.faceid.v20180301.Client; + const client = new FaceClient({ + credential: { + secretId: config.secretId, + secretKey: config.secretKey + }, + region: '', + profile: { + signMethod: 'HmacSHA256', + httpProfile: { + endpoint: 'faceid.tencentcloudapi.com', + reqMethod: 'POST', + reqTimeout: 30 // Request timeout, default 60s + } + } + }); + + const params = { + BizToken: bizToken, + InfoType: '0', + RuleId: config.ruleId, + BestFramesCount: 0 + }; + + const data = await client.GetDetectInfoEnhanced(params); + return data; +} diff --git a/frontend/desktop/src/pages/api/account/realNameAuth.ts b/frontend/desktop/src/pages/api/account/realNameAuth.ts deleted file mode 100644 index 100e718bf..000000000 --- a/frontend/desktop/src/pages/api/account/realNameAuth.ts +++ /dev/null @@ -1,219 +0,0 @@ -import { jsonRes } from '@/services/backend/response'; -import { enableRealNameAuth } from '@/services/enable'; -import { z } from 'zod'; -import type { NextApiRequest, NextApiResponse } from 'next'; -import * as tcsdk from 'tencentcloud-sdk-nodejs'; -import { verifyAccessToken } from '@/services/backend/auth'; -import { identityCodeValid } from '@/utils/tools'; -import { globalPrisma } from '@/services/backend/db/init'; - -type TencentCloudPhone3efConfig = { - secretId: string; - secretKey: string; -}; - -// type OtherBackendConfig = { ... }; - -// backend_authType -type ConfigMap = { - TENCENTCLOUD_tcloudphone3ef: TencentCloudPhone3efConfig; - // 'OTHER_BACKEND_authType': OtherBackendConfig; -}; - -type RealNameAuthProvider = { - id: string; - backend: string; - authType: string; - maxFailedTimes: number; - config: ConfigType; - createdAt: Date; - updatedAt: Date; -}; - -type ConfigType = { - [K in keyof ConfigMap]: RealNameAuthProvider extends { backend: infer B; authType: infer A } - ? `${B extends string ? B : never}_${A extends string ? A : never}` extends K - ? ConfigMap[K] - : never - : never; -}[keyof ConfigMap]; - -const bodySchema = z.object({ - name: z - .string() - .min(1, { message: 'Name must not be empty' }) - .max(20, { message: 'Name must not exceed 20 characters' }), - idCard: z.string().refine(identityCodeValid, { message: 'Invalid ID card number' }) -}); - -export default async function handler(req: NextApiRequest, res: NextApiResponse) { - if (!enableRealNameAuth) { - console.error('realNameAuth: Real name authentication not enabled'); - return jsonRes(res, { code: 503, message: 'Real name authentication not enabled' }); - } - - if (req.method !== 'POST') { - console.error('realNameAuth: 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 invaild' }); - - try { - const { name, idCard } = bodySchema.parse(req.body); - - const oauthProvider = await globalPrisma.oauthProvider.findFirst({ - where: { - userUid: payload.userUid, - providerType: 'PHONE' - } - }); - - if (!oauthProvider) { - console.error('realNameAuth: User has not bound phone number'); - return jsonRes(res, { code: 400, message: 'Mobile number not bound' }); - } - - const phone = oauthProvider.providerId; - - const realNameAuthProvider = (await globalPrisma.realNameAuthProvider.findFirst({ - where: { - backend: 'TENCENTCLOUD', - authType: 'tcloudphone3ef' - } - })) as RealNameAuthProvider | null; - - const config = realNameAuthProvider?.config; - - if (!config) { - throw new Error('realNameAuth: Real name authentication configuration not found'); - } - - const realNameInfo = await globalPrisma.userRealNameInfo.findUnique({ - where: { - userUid: payload.userUid - } - }); - - if (realNameInfo && realNameInfo.isVerified) { - console.info(`realNameAuth: User ${payload.userUid} has already been verified`); - return jsonRes(res, { - code: 409, - message: 'Identity verification has been completed, cannot be repeated.' - }); - } - - if (realNameInfo && realNameInfo.idVerifyFailedTimes >= realNameAuthProvider.maxFailedTimes) { - console.info( - `realNameAuth: User ${payload.userUid} has reached the maximum number of failed attempts` - ); - return jsonRes(res, { - code: 429, - message: 'You have exceeded the maximum number of attempts. Please submit a ticket' - }); - } - - const { code, data } = await tcloudphone3efVerifyService(phone, name, idCard, config); - - /* '-4' and '-5' are the results of chargeable interfaces, - and the number of failures is recorded for subsequent limitation. - */ - if (code === '-4' || code === '-5') { - await globalPrisma.userRealNameInfo.upsert({ - where: { userUid: payload.userUid }, - update: { - realName: name, - idCard: idCard, - phone: phone, - idVerifyFailedTimes: { - increment: 1 - }, - updatedAt: new Date() - }, - create: { - userUid: payload.userUid, - realName: name, - idCard: idCard, - phone: phone, - idVerifyFailedTimes: 1, - isVerified: false, - createdAt: new Date(), - updatedAt: new Date() - } - }); - } - - if (code !== 0) { - console.info( - `realNameAuth: Real name authentication failed,useruid ${payload.userUid} code:${code} data:${data}` - ); - return jsonRes(res, { - code: 400, - message: - 'Identity verification failed. Please ensure that the name, ID number, and mobile number are consistent' - }); - } - - await globalPrisma.userRealNameInfo.upsert({ - where: { userUid: payload.userUid }, - update: { - realName: name, - idCard: idCard, - phone: phone, - isVerified: true, - updatedAt: new Date() - }, - create: { - userUid: payload.userUid, - realName: name, - idCard: idCard, - phone: phone, - idVerifyFailedTimes: 0, - isVerified: true, - createdAt: new Date(), - updatedAt: new Date() - } - }); - - return jsonRes(res, { code: 200, message: 'Identity verification success', data: { name } }); - } catch (error) { - console.error('realNameAuth: Internal error'); - console.error(error); - return jsonRes(res, { code: 500, data: 'The server has encountered an error' }); - } -} - -async function tcloudphone3efVerifyService( - phone: string, - name: string, - idCard: string, - config: TencentCloudPhone3efConfig -) { - const FaceClient = tcsdk.faceid.v20180301.Client; - const client = new FaceClient({ - credential: { - secretId: config.secretId, - secretKey: config.secretKey - }, - profile: { - signMethod: 'HmacSHA256', - httpProfile: { - reqMethod: 'POST', - reqTimeout: 30 // Request timeout, default 60s - } - } - }); - - const res = await client.PhoneVerification({ - Phone: phone, - IdCard: idCard, - Name: name - }); - - if (res?.Result !== '0') { - return { code: res?.Result, data: res?.Description }; - } - - return { code: 0, data: res?.Description }; -} diff --git a/frontend/desktop/src/pages/api/auth/info.ts b/frontend/desktop/src/pages/api/auth/info.ts index afbb84b5d..3390bf444 100644 --- a/frontend/desktop/src/pages/api/auth/info.ts +++ b/frontend/desktop/src/pages/api/auth/info.ts @@ -19,36 +19,42 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) code: 401, message: 'invalid token' }); - const [regionData, globalData, realNameInfo, restrictedUser] = await Promise.all([ - prisma.userCr.findUnique({ - where: { - uid: regionUser.userCrUid - } - }), - globalPrisma.user.findUnique({ - where: { - uid: regionUser.userUid - }, - include: { - oauthProvider: { - select: { - providerType: true, - providerId: true + const [regionData, globalData, realNameInfo, enterpriseRealNameInfo, restrictedUser] = + await Promise.all([ + prisma.userCr.findUnique({ + where: { + uid: regionUser.userCrUid + } + }), + globalPrisma.user.findUnique({ + where: { + uid: regionUser.userUid + }, + include: { + oauthProvider: { + select: { + providerType: true, + providerId: true + } } } - } - }), - globalPrisma.userRealNameInfo.findUnique({ - where: { - userUid: regionUser.userUid - } - }), - globalPrisma.restrictedUser.findUnique({ - where: { - userUid: regionUser.userUid - } - }) - ]); + }), + globalPrisma.userRealNameInfo.findUnique({ + where: { + userUid: regionUser.userUid + } + }), + globalPrisma.enterpriseRealNameInfo.findUnique({ + where: { + userUid: regionUser.userUid + } + }), + globalPrisma.restrictedUser.findUnique({ + where: { + userUid: regionUser.userUid + } + }) + ]); if (!regionData || !globalData) return jsonRes(res, { @@ -66,6 +72,8 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) id: string; name: string; realName?: string; + enterpriseVerificationStatus?: string; + enterpriseRealName?: string; userRestrictedLevel?: number; } = { ...globalData, @@ -98,6 +106,11 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) info.realName = realNameInfo.realName || undefined; } + if (enterpriseRealNameInfo) { + info.enterpriseVerificationStatus = enterpriseRealNameInfo.verificationStatus || undefined; + info.enterpriseRealName = enterpriseRealNameInfo.enterpriseName || undefined; + } + if (restrictedUser) { info.userRestrictedLevel = restrictedUser.restrictedLevel; } diff --git a/frontend/desktop/src/pages/api/platform/getAuthConfig.ts b/frontend/desktop/src/pages/api/platform/getAuthConfig.ts index 82f094fa8..fea71d05a 100644 --- a/frontend/desktop/src/pages/api/platform/getAuthConfig.ts +++ b/frontend/desktop/src/pages/api/platform/getAuthConfig.ts @@ -66,7 +66,7 @@ export async function getAuthClientConfig(): Promise { try { if (process.env.NODE_ENV === 'development' || !global.AppConfig) { const filename = - process.env.NODE_ENV === 'development' ? 'data/config.yaml.local' : '/app/data/config.yaml'; + process.env.NODE_ENV === 'development' ? 'data/config.local.yaml' : '/app/data/config.yaml'; global.AppConfig = yaml.load(readFileSync(filename, 'utf-8')) as AppConfigType; } return genResAuthClientConfig(global.AppConfig.desktop.auth); diff --git a/frontend/desktop/src/pages/api/platform/getCloudConfig.ts b/frontend/desktop/src/pages/api/platform/getCloudConfig.ts index 17eb1613e..ab8baf4c9 100644 --- a/frontend/desktop/src/pages/api/platform/getCloudConfig.ts +++ b/frontend/desktop/src/pages/api/platform/getCloudConfig.ts @@ -25,7 +25,7 @@ export async function getCloudConfig(): Promise { if (!global.AppConfig) { const filename = process.env.NODE_ENV === 'development' - ? process.env.CONFIG_PATH || 'data/config.yaml.local' + ? process.env.CONFIG_PATH || 'data/config.local.yaml' : '/app/data/config.yaml'; global.AppConfig = yaml.load(readFileSync(filename, 'utf-8')) as AppConfigType; } diff --git a/frontend/desktop/src/pages/api/platform/getCommonConfig.ts b/frontend/desktop/src/pages/api/platform/getCommonConfig.ts index d9302c3ac..1de2dd5b7 100644 --- a/frontend/desktop/src/pages/api/platform/getCommonConfig.ts +++ b/frontend/desktop/src/pages/api/platform/getCommonConfig.ts @@ -18,17 +18,19 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) } function genResCommonClientConfig(common: CommonConfigType): CommonClientConfigType { return { + enterpriseRealNameAuthEnabled: !!common.enterpriseRealNameAuthEnabled, realNameAuthEnabled: !!common.realNameAuthEnabled, guideEnabled: !!common.guideEnabled, rechargeEnabled: !!common.rechargeEnabled, - cfSiteKey: common.cfSiteKey || '' + cfSiteKey: common.cfSiteKey || '', + enterpriseSupportingMaterials: common.enterpriseSupportingMaterials || '' }; } export async function getCommonClientConfig(): Promise { try { if (!global.AppConfig) { const filename = - process.env.NODE_ENV === 'development' ? 'data/config.yaml.local' : '/app/data/config.yaml'; + process.env.NODE_ENV === 'development' ? 'data/config.local.yaml' : '/app/data/config.yaml'; global.AppConfig = yaml.load(readFileSync(filename, 'utf-8')) as AppConfigType; } return genResCommonClientConfig(global.AppConfig.common); diff --git a/frontend/desktop/src/pages/api/platform/getLayoutConfig.ts b/frontend/desktop/src/pages/api/platform/getLayoutConfig.ts index d871a0cc1..28f77d7c4 100644 --- a/frontend/desktop/src/pages/api/platform/getLayoutConfig.ts +++ b/frontend/desktop/src/pages/api/platform/getLayoutConfig.ts @@ -16,7 +16,7 @@ export async function getLayoutConfig(): Promise { try { if (!global.AppConfig) { const filename = - process.env.NODE_ENV === 'development' ? 'data/config.yaml.local' : '/app/data/config.yaml'; + process.env.NODE_ENV === 'development' ? 'data/config.local.yaml' : '/app/data/config.yaml'; global.AppConfig = yaml.load(readFileSync(filename, 'utf-8')) as AppConfigType; } return global.AppConfig.desktop.layout || DefaultLayoutConfig; diff --git a/frontend/desktop/src/services/enable.ts b/frontend/desktop/src/services/enable.ts index 5f4caeb3a..91f700d9a 100644 --- a/frontend/desktop/src/services/enable.ts +++ b/frontend/desktop/src/services/enable.ts @@ -1,5 +1,7 @@ // for service export const enableRealNameAuth = () => global.AppConfig.common.realNameAuthEnabled || false; +export const enableEnterpriseRealNameAuth = () => + global.AppConfig.common.enterpriseRealNameAuthEnabled || false; export const enablePassword = () => global.AppConfig.desktop.auth.idp.password?.enabled || false; export const enableGithub = () => global.AppConfig.desktop.auth.idp.github?.enabled || false; export const enablePhoneSms = () => global.AppConfig.desktop.auth.idp.sms?.ali?.enabled || false; diff --git a/frontend/desktop/src/types/session.ts b/frontend/desktop/src/types/session.ts index b80fd6f4d..043a45bae 100644 --- a/frontend/desktop/src/types/session.ts +++ b/frontend/desktop/src/types/session.ts @@ -8,6 +8,8 @@ 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; readonly avatar: string; diff --git a/frontend/desktop/src/types/system.ts b/frontend/desktop/src/types/system.ts index 598221e41..5f237ff23 100644 --- a/frontend/desktop/src/types/system.ts +++ b/frontend/desktop/src/types/system.ts @@ -8,6 +8,8 @@ export type CloudConfigType = { }; export type CommonConfigType = { + enterpriseRealNameAuthEnabled: boolean; + enterpriseSupportingMaterials: string; realNameAuthEnabled: boolean; guideEnabled: boolean; apiEnabled: boolean; @@ -176,12 +178,24 @@ export type DesktopConfigType = { }; }; +export type RealNameOSSConfigType = { + accessKey: string; + accessKeySecret: string; + endpoint: string; + ssl?: boolean; + port?: number; + realNameBucket: string; + enterpriseRealNameBucket: string; +}; + export type AppConfigType = { cloud: CloudConfigType; common: CommonConfigType; database: DatabaseConfigType; desktop: DesktopConfigType; + realNameOSS: RealNameOSSConfigType; }; + export type AppClientConfigType = { cloud: CloudConfigType; common: CommonClientConfigType; @@ -189,6 +203,8 @@ export type AppClientConfigType = { }; export const DefaultCommonClientConfig: CommonClientConfigType = { + enterpriseRealNameAuthEnabled: false, + enterpriseSupportingMaterials: '', realNameAuthEnabled: false, guideEnabled: false, rechargeEnabled: false, diff --git a/frontend/desktop/src/utils/sessionConfig.ts b/frontend/desktop/src/utils/sessionConfig.ts index bc9c47b5a..c1c44563f 100644 --- a/frontend/desktop/src/utils/sessionConfig.ts +++ b/frontend/desktop/src/utils/sessionConfig.ts @@ -22,6 +22,8 @@ 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 || '', avatar: infoData.data?.info.avatarUri || '', diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index b91c087c3..b694e8c1f 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -135,6 +135,9 @@ 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) @@ -204,6 +207,9 @@ importers: react-draggable: specifier: ^4.4.6 version: 4.4.6(react-dom@18.2.0)(react@18.2.0) + react-dropzone: + specifier: ^14.2.3 + version: 14.2.3(react@18.2.0) react-hook-form: specifier: ^7.46.2 version: 7.48.2(react@18.2.0) @@ -241,6 +247,9 @@ 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