diff --git a/backend/pom.xml b/backend/pom.xml index d592004..bf0c898 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -55,6 +55,10 @@ org.springframework.boot spring-boot-starter-aop + + org.springframework.boot + spring-boot-starter-mail + com.baomidou mybatis-plus-spring-boot3-starter diff --git a/backend/src/main/java/com/imeeting/config/LettuceRedisConfig.java b/backend/src/main/java/com/imeeting/config/LettuceRedisConfig.java new file mode 100644 index 0000000..51e19fb --- /dev/null +++ b/backend/src/main/java/com/imeeting/config/LettuceRedisConfig.java @@ -0,0 +1,42 @@ +package com.imeeting.config; + +import io.lettuce.core.RedisClient; +import io.lettuce.core.RedisURI; +import io.lettuce.core.api.StatefulRedisConnection; +import io.lettuce.core.codec.StringCodec; +import org.springframework.boot.autoconfigure.data.redis.RedisProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.util.StringUtils; + +import java.time.Duration; + +@Configuration +public class LettuceRedisConfig { + + @Bean(destroyMethod = "shutdown") + public RedisClient redisClient(RedisProperties redisProperties) { + RedisURI.Builder builder = RedisURI.builder() + .withHost(redisProperties.getHost()) + .withPort(redisProperties.getPort()) + .withDatabase(redisProperties.getDatabase()); + + if (StringUtils.hasText(redisProperties.getUsername())) { + builder.withAuthentication(redisProperties.getUsername(), redisProperties.getPassword()); + } else if (StringUtils.hasText(redisProperties.getPassword())) { + builder.withPassword(redisProperties.getPassword().toCharArray()); + } + + Duration timeout = redisProperties.getTimeout(); + if (timeout != null && !timeout.isZero() && !timeout.isNegative()) { + builder.withTimeout(timeout); + } + + return RedisClient.create(builder.build()); + } + + @Bean(destroyMethod = "close") + public StatefulRedisConnection redisConnection(RedisClient redisClient) { + return redisClient.connect(StringCodec.UTF8); + } +} diff --git a/backend/src/main/java/com/imeeting/grpc/push/AndroidPushGrpcService.java b/backend/src/main/java/com/imeeting/grpc/push/AndroidPushGrpcService.java index b916dcd..9102f0a 100644 --- a/backend/src/main/java/com/imeeting/grpc/push/AndroidPushGrpcService.java +++ b/backend/src/main/java/com/imeeting/grpc/push/AndroidPushGrpcService.java @@ -177,6 +177,7 @@ public class AndroidPushGrpcService extends PushServiceGrpc.PushServiceImplBase } private void handleAck(AckRequest request) { + log.info(buildLog("gRPC请求", "ACK消息:" + request.getMessageId(), deviceId, appVersion, platform)); if (!validateConnected()) { return; } diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index 35c843f..a7378a1 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -19,7 +19,40 @@ spring: serialization: write-dates-as-timestamps: false time-zone: GMT+8 - + mail: + # SMTP服务器地址 + host: ${MAIL_HOST} + # 端口(QQ邮箱使用587或465) + port: 465 + # 你的完整邮箱地址 + username: ${MAIL_USERNAME} + # 授权码(不是登录密码!) + password: ${MAIL_PASSWORD} + # 默认编码 + default-encoding: UTF-8 + # 协议(默认为smtp) + protocol: smtp + # 测试连接(可选) + test-connection: false + # 额外属性配置 + properties: + mail: + smtp: + # 启用认证 + auth: true + # 启用STARTTLS加密(465端口为隐式SSL,无需STARTTLS) + starttls: + enable: false + required: false + # 465端口必须启用隐式SSL,否则连接会一直阻塞直到超时 + ssl: + enable: true + # 超时配置(避免线程阻塞) + connectiontimeout: 5000 + timeout: 3000 + writetimeout: 5000 + # 启用调试日志(生产环境建议关闭) + debug: true springdoc: api-docs: enabled: true diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts index f0fd372..bbf0e79 100644 --- a/frontend/src/api/auth.ts +++ b/frontend/src/api/auth.ts @@ -5,6 +5,21 @@ export interface CaptchaResponse { imageBase64: string; } +export interface PasswordPolicyPublic { + enabled: boolean; + minLength: number; + maxLength: number; + requireUppercase: boolean; + requireLowercase: boolean; + requireDigit: boolean; + requireSpecialChar: boolean; + specialCharSet?: string; + forbidUsernameContain: boolean; + forbidSequentialChars: boolean; + forbidRepeatedChars: boolean; + customRuleMessage?: string; +} + export interface TenantInfo { tenantId: number; tenantCode: string; @@ -39,11 +54,30 @@ export interface DeviceCodePayload { deviceName?: string; } +export interface PasswordRecoverySendCodePayload { + username: string; + captchaId?: string; + captchaCode?: string; + channel: "EMAIL"; +} + +export interface PasswordRecoveryResetPayload { + username: string; + channel: "EMAIL"; + code: string; + newPassword: string; +} + export async function fetchCaptcha() { const resp = await http.get("/sys/auth/captcha"); return resp.data.data as CaptchaResponse; } +export async function fetchPublicPasswordPolicy() { + const resp = await http.get("/sys/auth/password-policy/public"); + return resp.data.data as PasswordPolicyPublic; +} + export async function login(payload: LoginPayload) { const resp = await http.post("/sys/auth/login", payload); return resp.data.data as TokenResponse; @@ -63,3 +97,13 @@ export async function switchTenant(tenantId: number) { const resp = await http.post(`/sys/auth/switch-tenant?tenantId=${tenantId}`); return resp.data.data as TokenResponse; } + +export async function sendPasswordRecoveryCode(payload: PasswordRecoverySendCodePayload) { + const resp = await http.post("/sys/auth/password-recovery/send-code", payload); + return resp.data as { code: string; msg: string; data: boolean }; +} + +export async function resetPasswordByRecovery(payload: PasswordRecoveryResetPayload) { + const resp = await http.post("/sys/auth/password-recovery/reset", payload); + return resp.data.data as boolean; +} diff --git a/frontend/src/api/http.ts b/frontend/src/api/http.ts index f58ad78..aaee066 100644 --- a/frontend/src/api/http.ts +++ b/frontend/src/api/http.ts @@ -1,4 +1,4 @@ -import axios from "axios"; +import axios from "axios"; import { message } from "antd"; declare module "axios" { @@ -21,7 +21,15 @@ const refreshClient = axios.create({ timeout: 15000 }); -const AUTH_WHITELIST = ["/sys/auth/login", "/sys/auth/refresh", "/sys/auth/captcha", "/sys/auth/device-code"]; +const AUTH_WHITELIST = [ + "/sys/auth/login", + "/sys/auth/refresh", + "/sys/auth/captcha", + "/sys/auth/device-code", + "/sys/auth/password-policy/public", + "/sys/auth/password-recovery/send-code", + "/sys/auth/password-recovery/reset" +]; const API_SUCCESS_CODE = "200"; const REFRESH_AHEAD_MS = 60 * 1000; diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index c814e1a..3106ddc 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -1,4 +1,4 @@ -import http from "./http"; +import http from "./http"; import { BotCredential, DeviceInfo, RoleDataScope, SysPermission, SysRole, SysUser, UserProfile, SysParamVO, SysParamQuery, PageResult, PermissionNode @@ -114,6 +114,11 @@ export async function updateMyPassword(payload: any) { return resp.data.data as boolean; } +export async function resetUserPassword(id: number, payload: { newPassword: string }) { + const resp = await http.post(`/sys/api/users/${id}/reset-password`, payload); + return resp.data.data as boolean; +} + export async function getMyBotCredential() { const resp = await http.get("/sys/api/users/bot-credential"); return resp.data.data as BotCredential; diff --git a/frontend/src/components/shared/PageToolbar/PageToolbar.css b/frontend/src/components/shared/PageToolbar/PageToolbar.css new file mode 100644 index 0000000..daa2642 --- /dev/null +++ b/frontend/src/components/shared/PageToolbar/PageToolbar.css @@ -0,0 +1,21 @@ +.page-toolbar { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + width: 100%; + flex-wrap: wrap; +} + +.page-toolbar__group { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; + min-width: 0; +} + +.page-toolbar__group--end { + margin-left: auto; + justify-content: flex-end; +} diff --git a/frontend/src/components/shared/PageToolbar/index.tsx b/frontend/src/components/shared/PageToolbar/index.tsx new file mode 100644 index 0000000..e1b0cf1 --- /dev/null +++ b/frontend/src/components/shared/PageToolbar/index.tsx @@ -0,0 +1,21 @@ +import React from "react"; +import "./PageToolbar.css"; + +interface PageToolbarProps { + left?: React.ReactNode; + right?: React.ReactNode; + className?: string; +} + +const PageToolbar: React.FC = ({left, right, className = ""}) => { + const classes = ["page-toolbar", className].filter(Boolean).join(" "); + + return ( +
+
{left}
+
{right}
+
+ ); +}; + +export default PageToolbar; diff --git a/frontend/src/locales/en-US.json b/frontend/src/locales/en-US.json index c6b5080..330669a 100644 --- a/frontend/src/locales/en-US.json +++ b/frontend/src/locales/en-US.json @@ -258,6 +258,7 @@ "basicInfo": "Basic Info", "security": "Security", "currentPassword": "Current Password", + "passwordRules": "Password Rules", "newPassword": "New Password", "confirmNewPassword": "Confirm New Password", "saveChanges": "Save Changes", diff --git a/frontend/src/locales/zh-CN.json b/frontend/src/locales/zh-CN.json index 8762d78..c0a183c 100644 --- a/frontend/src/locales/zh-CN.json +++ b/frontend/src/locales/zh-CN.json @@ -259,6 +259,7 @@ "security": "安全设置", "currentPassword": "当前密码", "newPassword": "新密码", + "passwordRules": "密码规则", "confirmNewPassword": "确认新密码", "saveChanges": "保存修改", "updatePassword": "更新密码", diff --git a/frontend/src/pages/access/users/index.less b/frontend/src/pages/access/users/index.less index f301df3..bc53565 100644 --- a/frontend/src/pages/access/users/index.less +++ b/frontend/src/pages/access/users/index.less @@ -26,6 +26,24 @@ max-width: 400px; } +.users-content-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 18px 24px 14px; + border-bottom: 1px solid rgba(148, 163, 184, 0.16); + background: linear-gradient(180deg, rgba(248, 250, 252, 0.95) 0%, rgba(255, 255, 255, 0.75) 100%); +} + +.users-content-header__count { + font-size: 28px; + line-height: 1; + font-weight: 700; + color: #0f172a; + margin-bottom: 4px; +} + .user-avatar-placeholder { width: 40px; height: 40px; @@ -80,3 +98,16 @@ padding-left: 8px !important; padding-right: 8px !important; } + +@media (max-width: 768px) { + .users-content-header { + padding: 16px; + align-items: flex-start; + flex-direction: column; + } + + .users-search-input { + width: 100% !important; + max-width: none; + } +} diff --git a/frontend/src/pages/access/users/index.tsx b/frontend/src/pages/access/users/index.tsx index 8cea856..c929bef 100644 --- a/frontend/src/pages/access/users/index.tsx +++ b/frontend/src/pages/access/users/index.tsx @@ -1,14 +1,61 @@ -import { Avatar, Button, Card, Col, Drawer, Form, Input, Popconfirm, Row, Select, Space, Switch, Table, Tag, TreeSelect, Typography, Upload, message } from "antd"; +import { + Avatar, + Button, + Card, + Col, + Drawer, + Form, + Input, + Modal, + Popconfirm, + Row, + Select, + Space, + Switch, + Table, + Tag, + Tooltip, + TreeSelect, + Typography, + Upload, + message +} from "antd"; import type { DefaultOptionType } from "antd/es/select"; import { useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; -import { ApartmentOutlined, DeleteOutlined, EditOutlined, MinusCircleOutlined, PlusOutlined, SearchOutlined, ShopOutlined, UploadOutlined, UserOutlined } from "@ant-design/icons"; -import { createUser, deleteUser, getUserDetail, listOrgs, listRoles, listTenants, listUserRoles, listUsers, updateUser, uploadPlatformAsset } from "@/api"; +import { + ApartmentOutlined, + DeleteOutlined, + EditOutlined, + KeyOutlined, + MinusCircleOutlined, + PlusOutlined, + QuestionCircleOutlined, + SearchOutlined, + ShopOutlined, + UploadOutlined, + UserOutlined +} from "@ant-design/icons"; +import { + createUser, + deleteUser, + getUserDetail, + listOrgs, + listRoles, + listTenants, + listUserRoles, + listUsers, + resetUserPassword, + updateUser, + uploadPlatformAsset +} from "@/api"; +import {fetchPublicPasswordPolicy, type PasswordPolicyPublic} from "@/api/auth"; import { useDict } from "@/hooks/useDict"; import { usePermission } from "@/hooks/usePermission"; import PageHeader from "@/components/shared/PageHeader"; import PageContainer from "@/components/shared/PageContainer"; import { LOGIN_NAME_PATTERN, sanitizeLoginName } from "@/utils/loginName"; +import {buildPasswordPolicyValidator, buildPolicyHints} from "@/utils/password"; import { getStandardPagination } from "@/utils/pagination"; import type { SysOrg, SysRole, SysTenant, SysUser } from "@/types"; import "./index.less"; @@ -18,6 +65,7 @@ const { Title, Text } = Typography; type OrgTreeNode = { value: number; title: string; children: OrgTreeNode[] }; type Membership = { tenantId?: number; orgId?: number; orgName?: string }; +type ResetPasswordValues = { newPassword: string; confirmPassword: string }; function buildOrgTree(list: SysOrg[]): OrgTreeNode[] { const map = new Map(); @@ -91,6 +139,11 @@ export default function Users() { const [drawerOpen, setDrawerOpen] = useState(false); const [editing, setEditing] = useState(null); const [form] = Form.useForm(); + const [resetPasswordOpen, setResetPasswordOpen] = useState(false); + const [resetPasswordLoading, setResetPasswordLoading] = useState(false); + const [resetPasswordTarget, setResetPasswordTarget] = useState(null); + const [passwordPolicy, setPasswordPolicy] = useState(null); + const [resetPasswordForm] = Form.useForm(); const isPlatformMode = useMemo(() => { const profileStr = sessionStorage.getItem("userProfile"); @@ -165,6 +218,18 @@ export default function Users() { loadBaseData(); }, []); + useEffect(() => { + void (async () => { + try { + setPasswordPolicy(await fetchPublicPasswordPolicy()); + } catch { + setPasswordPolicy(null); + } + })(); + }, []); + + const policyHints = useMemo(() => buildPolicyHints(passwordPolicy), [passwordPolicy]); + useEffect(() => { loadUsersData(); }, [filterTenantId]); @@ -195,6 +260,8 @@ export default function Users() { ); }, [data, searchText]); + const activeFilterLabel = filterTenantId ? tenantMap[filterTenantId] || `Tenant ${filterTenantId}` : "全部租户"; + const openCreate = () => { setEditing(null); form.resetFields(); @@ -216,8 +283,6 @@ export default function Users() { form.setFieldsValue({ ...detail, roleIds: roleIds || [], - password: "", - confirmPassword: "", tenantId: (detail as any).tenantId || detail.memberships?.[0]?.tenantId, orgId: (detail as any).orgId || detail.memberships?.[0]?.orgId, memberships: detail.memberships || [] @@ -269,13 +334,12 @@ export default function Users() { userPayload.memberships = values.memberships || []; } - if (values.password) { - userPayload.password = values.password; - } - if (editing) { await updateUser(editing.userId, userPayload); } else { + if (values.password) { + userPayload.password = values.password; + } await createUser(userPayload); } @@ -287,6 +351,29 @@ export default function Users() { } }; + const openResetPassword = (record: SysUser) => { + setResetPasswordTarget(record); + resetPasswordForm.resetFields(); + setResetPasswordOpen(true); + }; + + const submitResetPassword = async () => { + if (!resetPasswordTarget) { + return; + } + const values = await resetPasswordForm.validateFields(); + setResetPasswordLoading(true); + try { + await resetUserPassword(resetPasswordTarget.userId, {newPassword: values.newPassword}); + message.success("密码已重置"); + setResetPasswordOpen(false); + setResetPasswordTarget(null); + resetPasswordForm.resetFields(); + } finally { + setResetPasswordLoading(false); + } + }; + const columns: any[] = [ { title: t("users.userInfo"), @@ -368,11 +455,17 @@ export default function Users() { { title: t("common.action"), key: "action", - width: 100, + width: 200, fixed: "right" as const, render: (_: any, record: SysUser) => ( {can("sys:user:update") && - ) - } - toolbar={ - <> - - {isPlatformMode && ( - } - style={{ width: 300 }} - value={searchText} - onChange={(event) => { +
+ + + +
+ + + {isPlatformMode && + } + className="users-search-input" style={{width: 300}} value={searchText} onChange={(event) => { setSearchText(event.target.value); setCurrent(1); - }} - allowClear - aria-label={t("common.search")} - /> - - + }} allowClear aria-label={t("common.search")}/> + + + + {can("sys:user:create") && } - - } - > - { - setCurrent(page); - setPageSize(size); - })} - /> + + - -
{ + setCurrent(page); + setPageSize(size); + })} + /> + + + - sanitizeLoginName(event?.target?.value)} extra={t("usersExt.usernameFormatTip", { defaultValue: "登录名只能输入数字、小写英文、@ 和 _" })}> - - - - - - - - + sanitizeLoginName(event?.target?.value)} + extra={t("usersExt.usernameFormatTip", {defaultValue: "登录名只能输入数字、小写英文、@ 和 _"})}> + - - - - - - - - - - + + - - - + - + - - - - {passwordValue && ( + {!editing ? ( + form.getFieldValue("username"))} + ]} + > + + + ) : null} + {!editing && passwordValue ? ( - )} - - + {!isPlatformMode && } - - - ({label: item.itemLabel, value: Number(item.itemValue)}))}/> + {isPlatformMode && + } {isPlatformMode && ( <> @@ -559,9 +626,8 @@ export default function Users() { ))} - + )} @@ -569,6 +635,71 @@ export default function Users() { )} - + + { + setResetPasswordOpen(false); + setResetPasswordTarget(null); + resetPasswordForm.resetFields(); + }} + onOk={() => void submitResetPassword()} + confirmLoading={resetPasswordLoading} + destroyOnClose + > +
+ + 新密码 + 0 ? ( +
    + {policyHints.map((hint) => ( +
  • {hint}
  • + ))} +
+ ) : ( + t("profile.passwordRules") + ) + } + overlayStyle={{maxWidth: 300}} + > + +
+ + } + name="newPassword" + validateFirst + rules={[ + {required: true, message: "请输入新密码"}, + {validator: buildPasswordPolicyValidator(passwordPolicy, () => resetPasswordTarget?.username)} + ]} + > + +
+ ({ + validator(_, value) { + if (!value || getFieldValue("newPassword") === value) { + return Promise.resolve(); + } + return Promise.reject(new Error(t("profile.passwordsDoNotMatch"))); + } + }) + ]} + > + + + +
+ ); } diff --git a/frontend/src/pages/auth/auth-shell.less b/frontend/src/pages/auth/auth-shell.less new file mode 100644 index 0000000..eaef596 --- /dev/null +++ b/frontend/src/pages/auth/auth-shell.less @@ -0,0 +1,315 @@ +.auth-shell { + min-height: 100vh; + padding: 32px 24px; + background: radial-gradient(circle at top left, rgba(22, 119, 255, 0.16), transparent 32%), + radial-gradient(circle at bottom right, rgba(59, 130, 246, 0.12), transparent 28%), + linear-gradient(180deg, #eef4fb 0%, #f7f9fc 100%); + display: flex; + align-items: center; + justify-content: center; +} + +.auth-shell__surface { + width: min(1120px, 100%); + min-height: 680px; + display: grid; + grid-template-columns: minmax(320px, 0.92fr) minmax(420px, 1fr); + border-radius: 28px; + overflow: hidden; + background: rgba(255, 255, 255, 0.82); + border: 1px solid rgba(148, 163, 184, 0.18); + box-shadow: 0 24px 60px rgba(15, 23, 42, 0.12); + backdrop-filter: blur(14px); +} + +.auth-shell__aside { + padding: 56px 48px; + color: #f8fbff; + background: radial-gradient(circle at top left, rgba(12, 74, 110, 0.4), transparent 50%), + linear-gradient(165deg, #0b1b36 0%, #0c4a6e 60%, #0369a1 100%); + display: flex; + flex-direction: column; + justify-content: space-between; + gap: 32px; + position: relative; +} + +.auth-shell__aside::after { + content: ""; + position: absolute; + inset: auto -80px -120px auto; + width: 240px; + height: 240px; + border-radius: 50%; + background: radial-gradient(circle, rgba(255, 255, 255, 0.15) 0%, rgba(255, 255, 255, 0) 68%); +} + +.auth-shell__aside-top { + display: flex; + flex-direction: column; + gap: 20px; + position: relative; + z-index: 1; +} + +.auth-shell__eyebrow { + width: fit-content; + display: inline-flex; + align-items: center; + gap: 8px; + padding: 8px 14px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.12); + border: 1px solid rgba(255, 255, 255, 0.18); + color: rgba(255, 255, 255, 0.95); + font-size: 13px; + font-weight: 600; +} + +.auth-shell__aside-extra { + padding: 16px 20px; + border-radius: 14px; + background: rgba(255, 255, 255, 0.08); + border: 1px solid rgba(255, 255, 255, 0.12); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); +} + +.auth-shell__aside-extra-title { + display: block; + margin-bottom: 10px; + color: rgba(255, 255, 255, 0.95); + font-size: 14px; + font-weight: 600; +} + +.auth-shell__aside-extra-list { + margin: 0; + padding-left: 18px; + color: rgba(226, 232, 240, 0.9); + font-size: 13px; + line-height: 1.8; +} + +.auth-shell__aside-extra-list li::marker { + color: #38bdf8; +} + +.auth-shell__aside-copy { + max-width: 360px; + position: relative; + z-index: 1; +} + +.auth-shell__aside-title.ant-typography { + margin: 0 0 16px; + color: #ffffff; + font-size: 36px; + line-height: 1.18; + letter-spacing: -0.8px; +} + +.auth-shell__aside-description.ant-typography { + margin: 0; + color: rgba(226, 232, 240, 0.9); + font-size: 15px; + line-height: 1.8; +} + +.auth-shell__highlights { + display: flex; + flex-direction: column; + gap: 14px; + position: relative; + z-index: 1; +} + +.auth-shell__highlight-item { + display: flex; + align-items: flex-start; + gap: 12px; + padding: 14px 16px; + border-radius: 16px; + background: rgba(255, 255, 255, 0.08); + border: 1px solid rgba(255, 255, 255, 0.08); +} + +.auth-shell__highlight-item .anticon { + margin-top: 4px; + color: #7dd3fc; +} + +.auth-shell__highlight-item .ant-typography { + color: rgba(241, 245, 249, 0.94); +} + +.auth-shell__main { + padding: 40px; + display: flex; + align-items: center; + justify-content: center; +} + +.auth-shell__panel { + width: min(460px, 100%); +} + +.auth-shell__panel-header { + margin-bottom: 28px; +} + +.auth-shell__panel-title.ant-typography { + margin: 0 0 8px; + color: #10233b; + font-size: 30px; + line-height: 1.2; + letter-spacing: -0.5px; +} + +.auth-shell__panel-subtitle.ant-typography { + display: block; + color: #64748b; + font-size: 14px; + line-height: 1.7; +} + +.auth-shell__panel-content .ant-form-item { + margin-bottom: 20px; +} + +.auth-shell__panel-content .ant-input-affix-wrapper, +.auth-shell__panel-content .ant-input, +.auth-shell__panel-content .ant-btn { + border-radius: 12px; +} + +.auth-shell__panel-content .ant-input-affix-wrapper, +.auth-shell__panel-content .ant-input { + min-height: 44px; +} + +.auth-shell__panel-content .ant-btn { + min-height: 44px; + font-weight: 600; +} + +.auth-shell__panel-footer { + margin-top: 24px; + padding-top: 20px; + border-top: 1px solid rgba(148, 163, 184, 0.18); +} + +.auth-form__inline { + display: flex; + gap: 12px; +} + +.auth-form__captcha-button, +.auth-form__code-button { + flex: 0 0 136px; +} + +.auth-form__captcha-button { + padding: 0; + overflow: hidden; +} + +.auth-form__captcha-button img { + display: block; + width: 100%; + height: 42px; + object-fit: cover; +} + +.auth-form__actions { + display: flex; + flex-direction: column; + gap: 12px; + margin-top: 8px; +} + +.auth-form__policy-hints { + margin: -4px 0 20px; + padding: 12px 14px; + border-radius: 14px; + background: rgba(241, 245, 249, 0.78); + border: 1px solid rgba(148, 163, 184, 0.18); +} + +.auth-form__policy-hints-title { + display: block; + margin-bottom: 8px; + color: #334155; + font-size: 13px; + font-weight: 600; +} + +.auth-form__policy-list { + margin: 0; + padding-left: 18px; + color: #64748b; + font-size: 13px; + line-height: 1.7; +} + +.auth-shell__footer-link { + color: #1677ff; + font-weight: 500; +} + +.auth-shell__footer-link.ant-btn-link { + padding: 0; + height: auto; +} + +@media (max-width: 960px) { + .auth-shell { + padding: 20px; + } + + .auth-shell__surface { + min-height: auto; + grid-template-columns: 1fr; + } + + .auth-shell__aside { + padding: 36px 28px 28px; + gap: 24px; + } + + .auth-shell__main { + padding: 28px 24px 32px; + } +} + +@media (max-width: 576px) { + .auth-shell { + padding: 12px; + } + + .auth-shell__aside { + padding: 28px 20px 24px; + } + + .auth-shell__aside-title.ant-typography { + font-size: 28px; + } + + .auth-shell__main { + padding: 24px 16px 28px; + } + + .auth-shell__panel-title.ant-typography { + font-size: 26px; + } + + .auth-form__inline { + flex-direction: column; + } + + .auth-form__captcha-button, + .auth-form__code-button { + flex-basis: auto; + width: 100%; + } +} diff --git a/frontend/src/pages/auth/components/AuthShell.tsx b/frontend/src/pages/auth/components/AuthShell.tsx new file mode 100644 index 0000000..2347149 --- /dev/null +++ b/frontend/src/pages/auth/components/AuthShell.tsx @@ -0,0 +1,81 @@ +import {CheckCircleFilled, SafetyCertificateOutlined} from "@ant-design/icons"; +import {Typography} from "antd"; +import type {ReactNode} from "react"; +import "../auth-shell.less"; + +const {Paragraph, Text, Title} = Typography; + +interface AuthShellProps { + eyebrow: string; + asideExtra?: ReactNode; + asideTitle: string; + asideDescription: string; + asideHighlights: string[]; + title: string; + subtitle: string; + children: ReactNode; + footer?: ReactNode; +} + +export default function AuthShell({ + eyebrow, + asideExtra, + asideTitle, + asideDescription, + asideHighlights, + title, + subtitle, + children, + footer + }: AuthShellProps) { + return ( +
+
+ + +
+
+
+ + {title} + + + {subtitle} + +
+ +
{children}
+ + {footer ?
{footer}
: null} +
+
+
+
+ ); +} diff --git a/frontend/src/pages/auth/forgot-password/index.tsx b/frontend/src/pages/auth/forgot-password/index.tsx new file mode 100644 index 0000000..7e78ca0 --- /dev/null +++ b/frontend/src/pages/auth/forgot-password/index.tsx @@ -0,0 +1,238 @@ +import {Button, Form, Input, Typography, message} from "antd"; +import {LockOutlined, MailOutlined, ReloadOutlined, SafetyOutlined, UserOutlined} from "@ant-design/icons"; +import {useEffect, useMemo, useState} from "react"; +import {useNavigate} from "react-router-dom"; +import {getSystemParamValue} from "@/api"; +import { + fetchCaptcha, + fetchPublicPasswordPolicy, + resetPasswordByRecovery, + sendPasswordRecoveryCode, + type CaptchaResponse, + type PasswordPolicyPublic +} from "@/api/auth"; +import {buildPasswordPolicyValidator, buildPolicyHints} from "@/utils/password"; +import AuthShell from "../components/AuthShell"; + +const {Text} = Typography; + +type RecoveryFormValues = { + username: string; + captchaCode: string; + code: string; + newPassword: string; + confirmPassword: string; +}; + +const RECOVERY_HIGHLIGHTS = [ + "验证码仅发送到已绑定邮箱,避免外部恶意重置。", + "重置完成后立即生效,旧密码将失效。", + "新密码需要满足当前系统安全策略。" +]; + +export default function ForgotPassword() { + const navigate = useNavigate(); + const [form] = Form.useForm(); + const [captcha, setCaptcha] = useState(null); + const [policy, setPolicy] = useState(null); + const [countdown, setCountdown] = useState(0); + const [sending, setSending] = useState(false); + const [captchaEnabled, setCaptchaEnabled] = useState(true); + const [submitting, setSubmitting] = useState(false); + + const policyHints = useMemo(() => buildPolicyHints(policy), [policy]); + + const reloadCaptcha = async () => { + const data = await fetchCaptcha(); + setCaptcha(data); + }; + + useEffect(() => { + const init = async () => { + const [policyData, captchaValue] = await Promise.all([ + fetchPublicPasswordPolicy(), + getSystemParamValue("security.captcha.enabled", "true") + ]); + + const enabled = captchaValue !== "false"; + setCaptchaEnabled(enabled); + setPolicy(policyData); + + if (enabled) { + await reloadCaptcha(); + } + }; + + void init(); + }, []); + + useEffect(() => { + if (countdown <= 0) { + return; + } + const timer = window.setTimeout(() => setCountdown((value) => value - 1), 1000); + return () => window.clearTimeout(timer); + }, [countdown]); + + const handleSendCode = async () => { + const fields = captchaEnabled ? ["username", "captchaCode"] : ["username"]; + const values = await form.validateFields(fields); + + if (captchaEnabled && !captcha?.captchaId) { + await reloadCaptcha(); + return; + } + + setSending(true); + try { + const resp = await sendPasswordRecoveryCode({ + username: values.username, + captchaId: captcha?.captchaId, + captchaCode: values.captchaCode, + channel: "EMAIL" + }); + message.success(resp.msg); + if (resp.data) { + setCountdown(60); + } + form.setFieldValue("captchaCode", ""); + if (captchaEnabled) { + await reloadCaptcha(); + } + } catch { + if (captchaEnabled) { + await reloadCaptcha(); + } + } finally { + setSending(false); + } + }; + + const handleSubmit = async (values: RecoveryFormValues) => { + setSubmitting(true); + try { + await resetPasswordByRecovery({ + username: values.username, + channel: "EMAIL", + code: values.code, + newPassword: values.newPassword + }); + message.success("密码已重置,请重新登录"); + navigate("/login", {replace: true}); + } finally { + setSubmitting(false); + } + }; + + return ( + 0 ? ( +
+ 密码规则 +
    + {policyHints.map((hint) => ( +
  • {hint}
  • + ))} +
+
+ ) : null + } + footer={ + + 想起密码了?{" "} + + + } + > +
+ + } autoComplete="username"/> + + + {captchaEnabled ? ( + +
+ } maxLength={6}/> + +
+
+ ) : null} + + +
+ + } maxLength={6}/> + + +
+
+ + form.getFieldValue("username")) + } + ]} + > + } autoComplete="new-password"/> + + + ({ + validator(_, value) { + if (!value || getFieldValue("newPassword") === value) { + return Promise.resolve(); + } + return Promise.reject(new Error("两次输入的新密码不一致")); + } + }) + ]} + > + } autoComplete="new-password"/> + + +
+ + +
+ +
+ ); +} diff --git a/frontend/src/pages/auth/login/index.tsx b/frontend/src/pages/auth/login/index.tsx index fe61326..e1a4e38 100644 --- a/frontend/src/pages/auth/login/index.tsx +++ b/frontend/src/pages/auth/login/index.tsx @@ -1,13 +1,14 @@ import { Button, Checkbox, Form, Input, Typography, message } from "antd"; import { LockOutlined, ReloadOutlined, SafetyOutlined, UserOutlined } from "@ant-design/icons"; import { useCallback, useEffect, useState } from "react"; +import {Link as RouterLink} from "react-router-dom"; import { useTranslation } from "react-i18next"; import { getCurrentUser, getOpenPlatformConfig, getSystemParamValue } from "@/api"; import { fetchCaptcha, login, type CaptchaResponse } from "@/api/auth"; import type { SysPlatformConfig } from "@/types"; import "./index.less"; -const { Title, Text, Link } = Typography; +const {Title, Text} = Typography; type LoginFormValues = { username: string; @@ -101,9 +102,7 @@ export default function Login() { } message.success(t("common.success")); - const searchParams = new URLSearchParams(window.location.search); - const redirect = searchParams.get("redirect"); - window.location.href = redirect || "/"; + window.location.href = "/"; } catch { if (captchaEnabled) { await loadCaptcha(); @@ -224,7 +223,7 @@ export default function Login() { {t("login.rememberMe")} - {t("login.forgotPassword")} + {t("login.forgotPassword")} @@ -234,12 +233,12 @@ export default function Login() { - {/*
*/} - {/* */} - {/* {t("login.demoAccount")} admin / {t("login.password")}{" "}*/} - {/* 123456*/} - {/* */} - {/*
*/} +
+ + {t("login.demoAccount")} admin / {t("login.password")}{" "} + 123456 + +
diff --git a/frontend/src/pages/auth/reset-password/index.tsx b/frontend/src/pages/auth/reset-password/index.tsx index 0e2def8..fa8a29b 100644 --- a/frontend/src/pages/auth/reset-password/index.tsx +++ b/frontend/src/pages/auth/reset-password/index.tsx @@ -1,10 +1,13 @@ -import { Button, Card, Form, Input, Layout, Typography, message } from "antd"; +import {Alert, Button, Form, Input, Typography, message} from "antd"; import { LockOutlined, LogoutOutlined } from "@ant-design/icons"; -import { useState } from "react"; +import {useEffect, useMemo, useState} from "react"; import { useNavigate } from "react-router-dom"; import { getCurrentUser, updateMyPassword } from "@/api"; +import {fetchPublicPasswordPolicy, type PasswordPolicyPublic} from "@/api/auth"; +import {buildPasswordPolicyValidator, buildPolicyHints} from "@/utils/password"; +import AuthShell from "../components/AuthShell"; -const { Title, Text } = Typography; +const {Text} = Typography; type ResetPasswordFormValues = { oldPassword: string; @@ -12,11 +15,35 @@ type ResetPasswordFormValues = { confirmPassword: string; }; +const RESET_HIGHLIGHTS = [ + "首次登录必须完成密码更新,避免继续使用初始凭证。", + "修改成功后保持当前登录态,无需再次验证。", + "若不是本人操作,请立即退出并联系管理员。" +]; + export default function ResetPassword() { const [loading, setLoading] = useState(false); + const [policy, setPolicy] = useState(null); + const [username, setUsername] = useState(); const navigate = useNavigate(); const [form] = Form.useForm(); + const policyHints = useMemo(() => buildPolicyHints(policy), [policy]); + + useEffect(() => { + const init = async () => { + const [profileResult, policyResult] = await Promise.allSettled([getCurrentUser(), fetchPublicPasswordPolicy()]); + + if (profileResult.status === "fulfilled") { + setUsername(profileResult.value.username); + } + + setPolicy(policyResult.status === "fulfilled" ? policyResult.value : null); + }; + + void init(); + }, []); + const goToLogin = () => { localStorage.removeItem("accessToken"); localStorage.removeItem("refreshToken"); @@ -46,53 +73,79 @@ export default function ResetPassword() { }; return ( - - -
- - - 首次登录请修改密码 - - 当前账号需要先更新初始密码,提交成功后会直接进入系统。 -
+ 如需稍后处理,请先退出当前账号。} + > + -
- - } /> - + + + } autoComplete="current-password"/> + - - } /> - + {policyHints.length > 0 ? ( +
+ 密码规则 +
    + {policyHints.map((hint) => ( +
  • {hint}
  • + ))} +
+
+ ) : null} - ({ - validator(_, value) { - if (!value || getFieldValue("newPassword") === value) { - return Promise.resolve(); - } - return Promise.reject(new Error("两次输入的新密码不一致")); + username)} + ]} + > + } autoComplete="new-password"/> + + + ({ + validator(_, value) { + if (!value || getFieldValue("newPassword") === value) { + return Promise.resolve(); } - }) - ]} - > - } /> - + return Promise.reject(new Error("两次输入的新密码不一致")); + } + }) + ]} + > + } autoComplete="new-password"/> + - - - - -
-
+ + + ); } diff --git a/frontend/src/pages/profile/index.less b/frontend/src/pages/profile/index.less new file mode 100644 index 0000000..c9a0897 --- /dev/null +++ b/frontend/src/pages/profile/index.less @@ -0,0 +1,162 @@ +.profile-page { + width: 100%; +} + +.profile-side-stack { + width: 100%; +} + +.profile-summary-card, +.profile-help-card, +.profile-tabs-card { + overflow: hidden; +} + +.profile-summary-card__header { + display: flex; + flex-direction: column; + align-items: center; + gap: 16px; + padding-bottom: 20px; +} + +.profile-summary-card__avatar-trigger { + width: fit-content; + cursor: pointer; +} + +.profile-summary-card__avatar.ant-avatar { + background: linear-gradient(135deg, #1677ff 0%, #57a7ff 100%); + box-shadow: 0 16px 28px rgba(22, 119, 255, 0.22); +} + +.profile-summary-card__identity { + text-align: center; +} + +.profile-summary-card__title.ant-typography { + margin: 0 0 4px; +} + +.profile-summary-card__tags { + justify-content: center; + width: 100%; + margin: 0; +} + +.profile-summary-card__meta { + margin-top: 16px; + padding-top: 20px; + border-top: 1px solid rgba(148, 163, 184, 0.16); +} + +.profile-summary-card__meta .ant-descriptions-view { + border-radius: 14px; + overflow: hidden; +} + +.profile-summary-card__meta .ant-descriptions-item-label { + width: 96px; + color: #64748b; +} + +.profile-help-card__title.ant-typography { + margin-bottom: 12px; +} + +.profile-help-card__list { + display: flex; + flex-direction: column; + gap: 10px; + color: #475569; + line-height: 1.7; +} + +.profile-help-card__alert { + margin-top: 16px; + border-radius: 14px; +} + +.profile-tabs-card .ant-card-body { + padding: 0; +} + +.profile-tabs .ant-tabs-nav { + margin: 0; + padding: 0 24px; + border-bottom: 1px solid rgba(148, 163, 184, 0.16); + background: linear-gradient(180deg, rgba(248, 250, 252, 0.95) 0%, rgba(255, 255, 255, 0.75) 100%); +} + +.profile-tabs .ant-tabs-content-holder { + padding: 24px; +} + +.profile-form .ant-form-item { + margin-bottom: 20px; +} + +.profile-upload-panel { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 16px 18px; + border-radius: 16px; + border: 1px dashed rgba(59, 130, 246, 0.25); + background: rgba(248, 250, 252, 0.9); +} + +.profile-upload-panel__hint { + margin-top: 4px; + color: #64748b; + font-size: 13px; +} + +.profile-security-alert, +.profile-credential-panel__card { + margin-bottom: 20px; +} + +.profile-security-alert { + border-radius: 14px; +} + +.profile-credential-panel { + display: flex; + flex-direction: column; +} + +.profile-credential-panel__card { + padding: 0; + background: transparent; + box-shadow: none; +} + +.profile-credential-panel__card .ant-card-body { + padding: 0; +} + +.profile-credential-panel__descriptions .ant-descriptions-view { + border-radius: 14px; + overflow: hidden; +} + +.profile-tab-actions { + margin: 8px 0 0; +} + +@media (max-width: 768px) { + .profile-tabs .ant-tabs-nav { + padding: 0 16px; + } + + .profile-tabs .ant-tabs-content-holder { + padding: 16px; + } + + .profile-upload-panel { + align-items: flex-start; + flex-direction: column; + } +} diff --git a/frontend/src/pages/profile/index.tsx b/frontend/src/pages/profile/index.tsx index 657df28..46221e1 100644 --- a/frontend/src/pages/profile/index.tsx +++ b/frontend/src/pages/profile/index.tsx @@ -1,13 +1,42 @@ -import { Alert, Avatar, Button, Card, Col, Descriptions, Form, Input, Row, Space, Tabs, Tag, Typography, Upload, message } from "antd"; -import { KeyOutlined, LockOutlined, ReloadOutlined, SaveOutlined, SolutionOutlined, UploadOutlined, UserOutlined } from "@ant-design/icons"; -import { useEffect, useState } from "react"; +import { + Alert, + Avatar, + Button, + Card, + Col, + Descriptions, + Form, + Input, + Row, + Space, + Tabs, + Tag, + Tooltip, + Typography, + Upload, + message +} from "antd"; +import { + KeyOutlined, + LockOutlined, + QuestionCircleOutlined, + ReloadOutlined, + SaveOutlined, + SolutionOutlined, + UploadOutlined, + UserOutlined +} from "@ant-design/icons"; +import {useEffect, useMemo, useState} from "react"; import { useTranslation } from "react-i18next"; +import {fetchPublicPasswordPolicy, type PasswordPolicyPublic} from "@/api/auth"; import { generateMyBotCredential, getCurrentUser, getMyBotCredential, updateMyPassword, updateMyProfile, uploadPlatformAsset } from "@/api"; import PageHeader from "@/components/shared/PageHeader"; import type { BotCredential, UserProfile } from "@/types"; +import {buildPasswordPolicyValidator, buildPolicyHints} from "@/utils/password"; import AvatarCropDialog, { type CropModalState } from "./AvatarCropDialog"; +import "./index.less"; -const { Paragraph, Title, Text } = Typography; +const {Paragraph, Text, Title} = Typography; export default function Profile() { const { t } = useTranslation(); @@ -18,6 +47,7 @@ export default function Profile() { const [avatarUploading, setAvatarUploading] = useState(false); const [user, setUser] = useState(null); const [credential, setCredential] = useState(null); + const [policy, setPolicy] = useState(null); const [profileForm] = Form.useForm(); const [pwdForm] = Form.useForm(); const [cropState, setCropState] = useState({ @@ -25,7 +55,7 @@ export default function Profile() { src: "", fileName: "", mimeType: "image/jpeg", - targetSize: 300, + targetSize: 300 }); const loadUser = async () => { @@ -34,8 +64,6 @@ export default function Profile() { const data = await getCurrentUser(); setUser(data); sessionStorage.setItem("userProfile", JSON.stringify(data)); - localStorage.setItem("displayName", data.displayName || data.username || ""); - localStorage.setItem("username", data.username || localStorage.getItem("username") || ""); window.dispatchEvent(new Event("user-profile-updated")); profileForm.setFieldsValue(data); } finally { @@ -54,17 +82,26 @@ export default function Profile() { }; useEffect(() => { - loadUser(); - loadCredential(); + void loadUser(); + void loadCredential(); + void (async () => { + try { + setPolicy(await fetchPublicPasswordPolicy()); + } catch { + setPolicy(null); + } + })(); }, []); + const policyHints = useMemo(() => buildPolicyHints(policy), [policy]); + const handleUpdateProfile = async () => { try { const values = await profileForm.validateFields(); setSaving(true); await updateMyProfile(values); message.success(t("common.success")); - loadUser(); + await loadUser(); } finally { setSaving(false); } @@ -78,7 +115,7 @@ export default function Profile() { src: String(reader.result || ""), fileName: file.name, mimeType: file.type === "image/png" ? "image/png" : "image/jpeg", - targetSize: 300, + targetSize: 300 }); }; reader.onerror = () => message.error(t("common.error")); @@ -104,7 +141,10 @@ export default function Profile() { try { const values = await pwdForm.validateFields(); setSaving(true); - await updateMyPassword(values); + await updateMyPassword({ + oldPassword: values.oldPassword, + newPassword: values.newPassword + }); const profile = await getCurrentUser(); setUser(profile); sessionStorage.setItem("userProfile", JSON.stringify(profile)); @@ -130,58 +170,152 @@ export default function Profile() { const renderValue = (value?: string) => value || "-"; const avatarUrlValue = Form.useWatch("avatarUrl", profileForm) as string | undefined; const avatarUrl = avatarUrlValue?.trim() || undefined; + const userStatus = user ? (user.status === 0 ? 禁用 : 启用) : "-"; return ( -
+
- -
- - -
- } style={{ backgroundColor: "#1677ff", marginBottom: 16 }} /> + +
+ + +
+ +
+ } + className="profile-summary-card__avatar" + /> +
+
+ +
+ + {user?.displayName || "-"} + + + @{user?.username || "-"} + +
+ + + {user?.isPlatformAdmin ? {t("users.platformAdmin")} : null} + {user?.isTenantAdmin ? 租户管理员 : null} + {user && !user.isPlatformAdmin && !user.isTenantAdmin ? + {t("profile.standardUser")} : null} + {user && !user.isPlatformAdmin && user.hasPlatformAdminPrivilege ? + 可切换平台管理员 : null} +
- - {user?.displayName} - @{user?.username} -
- {user?.isPlatformAdmin ? {t("users.platformAdmin")} : null} - {user?.isTenantAdmin ? 租户管理员 : null} - {!user?.isPlatformAdmin && !user?.isTenantAdmin ? {t("profile.standardUser")} : null} - {!user?.isPlatformAdmin && user?.hasPlatformAdminPrivilege ? 可切换平台管理员 : null} -
-
+ + {renderValue(user?.email)} + }, + { + key: "phone", + label: t("users.phone"), + children: {renderValue(user?.phone)} + }, + { + key: "status", + label: t("common.status"), + children: userStatus + } + ]} + /> + + + + + 安全提示 + +
+
头像上传后会立即填充到个人资料表单,保存后对外生效。
+
密码修改成功后会刷新当前会话中的个人资料缓存。
+
Bot 凭证重新生成后,旧密钥会立即失效。
+
+ {user?.pwdResetRequired ? ( + + ) : null} +
+
- - + + {t("profile.basicInfo")}, + label: ( + + {t("profile.basicInfo")} + + ), children: ( -
- - - - - - - - - - - - - - - -
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + +
+
+ {t("profile.uploadAvatar")} +
建议上传方形图片,裁剪后会生成统一头像尺寸。
+
+ + + +
+ +
@@ -191,13 +325,55 @@ export default function Profile() { }, { key: "password", - label: {t("profile.security")}, + label: ( + + {t("profile.security")} + + ), children: ( - + + + - + + {t("profile.newPassword")} + 0 ? ( +
    + {policyHints.map((hint) => ( +
  • {hint}
  • + ))} +
+ ) : ( + t("profile.passwordRules") + ) + } + overlayStyle={{maxWidth: 300}} + > + +
+ + } + name="newPassword" + validateFirst + rules={[ + {required: true}, + { + validator: buildPasswordPolicyValidator(policy, () => user?.username) + } + ]} + >
-
+
@@ -228,27 +404,32 @@ export default function Profile() { }, { key: "bot-credential", - label: {t("profile.botCredentialTab")}, + label: ( + + {t("profile.botCredentialTab")} + + ), children: ( -
- - +
+ + {t("profile.botBound")} - : {t("profile.botUnbound")} + children: credential?.bound ? {t("profile.botBound")} : + {t("profile.botUnbound")} }, { key: "bot-id", @@ -257,7 +438,9 @@ export default function Profile() { {credential.botId} - ) : "-" + ) : ( + "-" + ) }, { key: "bot-secret", @@ -266,34 +449,34 @@ export default function Profile() { {credential.botSecret} - ) : t("profile.botSecretHidden") + ) : ( + t("profile.botSecretHidden") + ) }, { key: "last-access-time", label: t("profile.botLastAccessTime"), - children: renderValue(credential?.lastAccessTime) + children: {renderValue(credential?.lastAccessTime)} }, { key: "last-access-ip", label: t("profile.botLastAccessIp"), - children: renderValue(credential?.lastAccessIp) + children: {renderValue(credential?.lastAccessIp)} } ]} /> + -
- -
- +
+ +
) } @@ -311,4 +494,3 @@ export default function Profile() {
); } - diff --git a/frontend/src/routes/index.tsx b/frontend/src/routes/index.tsx index ffbb53c..7a7c839 100644 --- a/frontend/src/routes/index.tsx +++ b/frontend/src/routes/index.tsx @@ -8,7 +8,7 @@ const Login = lazy(() => import("@/pages/auth/login")); const ResetPassword = lazy(() => import("@/pages/auth/reset-password")); const MeetingPreview = lazy(() => import("@/pages/business/MeetingPreview")); const PublicDeviceMeetingCreate = lazy(() => import("@/pages/business/PublicDeviceMeetingCreate")); - +const ForgotPassword = lazy(() => import("@/pages/auth/forgot-password")); function RouteFallback() { let platformName = "iMeeting"; try { @@ -71,6 +71,7 @@ export default function AppRoutes() { } /> } /> + }/> } diff --git a/frontend/src/utils/password.ts b/frontend/src/utils/password.ts new file mode 100644 index 0000000..63886f3 --- /dev/null +++ b/frontend/src/utils/password.ts @@ -0,0 +1,125 @@ +import type {PasswordPolicyPublic} from "@/api/auth"; + +const DEFAULT_SPECIAL_CHARS = "!@#$%^&*()_+-=[]{}|;:',.<>/?`~\"\\"; +type UsernameResolver = string | undefined | (() => string | undefined); + +/** + * 根据密码策略生成可读的规则提示文本,用于问号 Tooltip 展示。 + */ +export function buildPolicyHints(policy: PasswordPolicyPublic | null): string[] { + if (!policy || policy.enabled === false) { + return ["密码长度至少 6 位"]; + } + const hints: string[] = []; + hints.push(`长度 ${policy.minLength}-${policy.maxLength} 位`); + if (policy.requireUppercase) hints.push("包含大写字母"); + if (policy.requireLowercase) hints.push("包含小写字母"); + if (policy.requireDigit) hints.push("包含数字"); + if (policy.requireSpecialChar) { + const chars = policy.specialCharSet?.trim() || DEFAULT_SPECIAL_CHARS; + hints.push(`包含特殊字符(如 ${chars})`); + } + if (policy.forbidUsernameContain) hints.push("不能包含登录名"); + if (policy.forbidSequentialChars) hints.push("不能包含 3 位及以上连续字符(如 abc、123)"); + if (policy.forbidRepeatedChars) hints.push("不能包含 3 位及以上重复字符(如 aaa)"); + if (policy.customRuleMessage) hints.push(policy.customRuleMessage); + return hints; +} + +function hasSequentialChars(value: string): boolean { + for (let i = 0; i + 2 < value.length; i += 1) { + const a = value.charCodeAt(i); + const b = value.charCodeAt(i + 1); + const c = value.charCodeAt(i + 2); + if (b - a === 1 && c - b === 1) return true; // 递增:abc / 123 + if (a - b === 1 && b - c === 1) return true; // 递减:cba / 321 + } + return false; +} + +function hasRepeatedChars(value: string): boolean { + for (let i = 0; i + 2 < value.length; i += 1) { + if (value[i] === value[i + 1] && value[i + 1] === value[i + 2]) { + return true; + } + } + return false; +} + +function resolveUsername(username: UsernameResolver): string | undefined { + return typeof username === "function" ? username() : username; +} + +function containsConfiguredSpecialChar(password: string, specialCharSet: string): boolean { + for (const char of password) { + if (specialCharSet.includes(char)) { + return true; + } + } + return false; +} + +/** + * 按密码策略校验密码,返回第一条未满足的提示;全部满足返回 null。 + * 用于表单的实时校验(边输入边提示)。 + */ +export function validatePasswordAgainstPolicy( + policy: PasswordPolicyPublic | null, + password: string, + username?: string +): string | null { + if (!password) { + return null; // 空值交由 required 规则处理 + } + + if (!policy || policy.enabled === false) { + return password.length >= 6 ? null : "密码长度至少 6 位"; + } + + if (password.length < policy.minLength) { + return `密码长度不能少于 ${policy.minLength} 位`; + } + if (password.length > policy.maxLength) { + return `密码长度不能超过 ${policy.maxLength} 位`; + } + if (policy.requireUppercase && !/[A-Z]/.test(password)) { + return "密码需包含大写字母"; + } + if (policy.requireLowercase && !/[a-z]/.test(password)) { + return "密码需包含小写字母"; + } + if (policy.requireDigit && !/[0-9]/.test(password)) { + return "密码需包含数字"; + } + if (policy.requireSpecialChar) { + const chars = policy.specialCharSet?.trim() || DEFAULT_SPECIAL_CHARS; + if (!containsConfiguredSpecialChar(password, chars)) { + return "密码需包含特殊字符"; + } + } + if (policy.forbidUsernameContain && username) { + const name = username.trim().toLowerCase(); + if (name && password.toLowerCase().includes(name)) { + return "密码不能包含登录名"; + } + } + if (policy.forbidSequentialChars && hasSequentialChars(password)) { + return "密码不能包含 3 位及以上连续字符"; + } + if (policy.forbidRepeatedChars && hasRepeatedChars(password)) { + return "密码不能包含 3 位及以上重复字符"; + } + return null; +} + +export function buildPasswordPolicyValidator( + policy: PasswordPolicyPublic | null, + username?: UsernameResolver +) { + return async (_: unknown, value: string) => { + const error = validatePasswordAgainstPolicy(policy, value, resolveUsername(username)); + if (error) { + throw new Error(error); + } + }; +} diff --git a/imeeting-h5/src/App.tsx b/imeeting-h5/src/App.tsx index acb259a..a89580d 100644 --- a/imeeting-h5/src/App.tsx +++ b/imeeting-h5/src/App.tsx @@ -7,6 +7,7 @@ import ProtectedRoute from "@/routes/ProtectedRoute"; import { hasAccessToken } from "@/utils/auth"; const LoginPage = lazy(() => import("@/pages/login")); +const ForgotPasswordPage = lazy(() => import("@/pages/forgot-password")); const MeetingsPage = lazy(() => import("@/pages/meetings")); const MeetingDetailPage = lazy(() => import("@/pages/meeting-detail")); const MeetingPreviewPage = lazy(() => import("@/pages/meeting-preview")); @@ -24,6 +25,7 @@ export default function App() { } /> } /> + }/> } /> +
+ + 忘记密码 + +
+ {platformConfig?.icpInfo || platformConfig?.copyrightInfo ? (
{platformConfig?.icpInfo ?
{platformConfig.icpInfo}
: null} diff --git a/imeeting-h5/src/styles/global.css b/imeeting-h5/src/styles/global.css index f336c45..e33675b 100644 --- a/imeeting-h5/src/styles/global.css +++ b/imeeting-h5/src/styles/global.css @@ -162,6 +162,12 @@ button { gap: 24px; } +.login-page--compact { + gap: 16px; + padding-top: 24px; + padding-bottom: 24px; +} + .login-page__hero { padding: 8px 4px; } @@ -181,6 +187,10 @@ button { padding: 24px; } +.login-card--compact .ant-card-body { + padding: 20px; +} + .login-card__brand { display: flex; align-items: center; @@ -233,6 +243,36 @@ button { line-height: 1.8; } +.login-card__actions { + display: flex; + justify-content: flex-end; + margin-top: 12px; +} + +.login-card__link { + color: var(--h5-primary); + font-size: 13px; + font-weight: 600; +} + +.captcha-preview--compact { + margin-top: -8px; + margin-bottom: 14px; + padding: 8px 12px; +} + +.login-inline-field { + display: grid; + grid-template-columns: minmax(0, 1fr) 92px; + gap: 10px; +} + +.login-form__hint-list { + color: var(--h5-text-subtle); + font-size: 12px; + line-height: 1.6; +} + .meeting-card-list { display: flex; flex-direction: column; @@ -542,6 +582,11 @@ button { } @media (max-width: 520px) { + .login-page { + padding-left: 14px; + padding-right: 14px; + } + .page-header__title { font-size: 24px; } @@ -558,4 +603,8 @@ button { .inline-actions .ant-btn { width: 100%; } + + .login-inline-field { + grid-template-columns: minmax(0, 1fr) 84px; + } } diff --git a/imeeting-h5/tsconfig.tsbuildinfo b/imeeting-h5/tsconfig.tsbuildinfo index 1af707c..cbb1692 100644 --- a/imeeting-h5/tsconfig.tsbuildinfo +++ b/imeeting-h5/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/app.tsx","./src/main.tsx","./src/api/auth.ts","./src/api/http.ts","./src/api/meeting.ts","./src/api/platform.ts","./src/api/user.ts","./src/components/bottomnav.tsx","./src/components/loadingscreen.tsx","./src/components/meetingcontent.tsx","./src/components/pageheader.tsx","./src/components/platformconfigprovider.tsx","./src/components/preview/meetingpreviewview.tsx","./src/components/preview/meetinganalysis.ts","./src/hooks/usepagetitle.ts","./src/layouts/mainlayout.tsx","./src/pages/about/index.tsx","./src/pages/login/index.tsx","./src/pages/meeting-detail/index.tsx","./src/pages/meeting-preview/index.tsx","./src/pages/meetings/index.tsx","./src/pages/password/index.tsx","./src/pages/profile/index.tsx","./src/pages/scan-confirm/index.tsx","./src/routes/protectedroute.tsx","./src/types/index.ts","./src/types/platform.ts","./src/utils/auth.ts","./src/utils/meeting.ts"],"version":"5.9.3"} \ No newline at end of file +{"root":["./src/app.tsx","./src/main.tsx","./src/api/auth.ts","./src/api/http.ts","./src/api/meeting.ts","./src/api/platform.ts","./src/api/user.ts","./src/components/bottomnav.tsx","./src/components/loadingscreen.tsx","./src/components/pageheader.tsx","./src/components/platformconfigprovider.tsx","./src/components/preview/meetingpreviewview.tsx","./src/components/preview/meetinganalysis.ts","./src/hooks/usepagetitle.ts","./src/layouts/mainlayout.tsx","./src/pages/forgot-password/index.tsx","./src/pages/login/index.tsx","./src/pages/meeting-detail/index.tsx","./src/pages/meeting-preview/index.tsx","./src/pages/meetings/index.tsx","./src/pages/password/index.tsx","./src/pages/profile/index.tsx","./src/pages/scan-confirm/index.tsx","./src/routes/protectedroute.tsx","./src/types/index.ts","./src/types/platform.ts","./src/utils/auth.ts","./src/utils/meeting.ts","./src/utils/password.ts"],"version":"5.9.3"} \ No newline at end of file