From 5f5eb739c22ddebdc6c60fcde7eda265dd877fd9 Mon Sep 17 00:00:00 2001 From: xiaozhu <2395895331@qq.com> Date: Tue, 1 Sep 2026 15:03:45 +0800 Subject: [PATCH] =?UTF-8?q?feat:=E8=B4=A6=E5=8F=B7=E8=AE=BE=E7=BD=AE+?= =?UTF-8?q?=E5=AF=86=E7=A0=81=E9=87=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- API.md | 50 ++++ backend/src/backend/api/auth.py | 126 ++++++-- backend/src/backend/api/platform/employees.py | 27 ++ backend/tests/test_auth_profile_password.py | 39 +++ frontend/.gitignore | 3 + .../common/AccountSettingsDialog.tsx | 269 ++++++++++++++++++ frontend/app/components/common/Topbar.tsx | 86 +++++- frontend/app/context/AuthContext.tsx | 46 ++- .../features/admin/ResetPasswordDialog.tsx | 127 +++++++++ .../app/features/admin/UserFormDialog.tsx | 20 ++ .../app/features/admin/UserManagementPage.tsx | 37 +++ frontend/app/services/api/boundApi.ts | 4 + .../app/services/api/platformEmployees.ts | 10 + 13 files changed, 815 insertions(+), 29 deletions(-) create mode 100644 backend/tests/test_auth_profile_password.py create mode 100644 frontend/app/components/common/AccountSettingsDialog.tsx create mode 100644 frontend/app/features/admin/ResetPasswordDialog.tsx diff --git a/API.md b/API.md index efae065..3bb71d7 100644 --- a/API.md +++ b/API.md @@ -50,6 +50,42 @@ > - `is_system_admin: bool` —— 派生自 `users.platform_role_id` 指向的角色 `role_code == 'admin'` 且用户状态为 `active`。前端据此决定是否渲染"系统管理"入口。 > - `permissions: string[]` —— 当前用户通过其平台角色(`platform_role_id`)间接持有的菜单权限码列表(`permission_code`),按字典序排列;未分配平台角色时为空数组。前端据此过滤菜单与 `` 路由守卫。**仅控制前端展示,不参与后端 endpoint 鉴权**——后端鉴权继续由 `system_admin_context`(`role_code == 'admin'`)与 workspace membership 负责。详见 §7.12-7.14。 +### 1.1 登录 / 会话 + +| 方法 | 路径 | 说明 | +|---|---|---| +| `POST` | `/api/v1/auth/login` | 校验用户名密码,种 `access_token` HttpOnly Cookie,返回 `user` + `workspaces` | +| `POST` | `/api/v1/auth/logout` | 清除 Cookie(幂等) | +| `GET` | `/api/v1/auth/me` | 返回当前登录用户与可访问 workspace 列表 | + +### 1.2 `PATCH /api/v1/auth/me` — 修改本人资料 + +当前登录用户修改自己的显示名 / 邮箱。不需要 `workspace_id`。 + +- **请求体**(至少提供一个字段;多余字段 → 422): + +| 字段 | 类型 | 限制 | 说明 | +|---|---|---|---| +| `display_name` | string | 1~100 | trim 后写入 | +| `email` | string \| null | ≤255 | trim 后写入;空字符串归一为 `null`;与其他未软删用户冲突 → 409 "邮箱已存在" | + +- 不可通过本端点修改:`username`、`password`、`status`、`platform_role_id`。 +- **响应 200**:`data.user` 与 `GET /me` 中的 `user` 形状一致(含 `role_code` / `is_system_admin` / `permissions`)。 + +### 1.3 `POST /api/v1/auth/password` — 本人修改密码 + +- **请求体**: + +| 字段 | 类型 | 限制 | 说明 | +|---|---|---|---| +| `current_password` | string | 1~72 | 当前密码 | +| `new_password` | string | 8~72 | 新密码 | + +- 当前密码错误 → 400 "当前密码不正确"。 +- 新密码与当前密码相同 → 400 "新密码不能与当前密码相同"。 +- **成功后清除会话 Cookie**,客户端须引导用户重新登录。 +- **响应 200**:`data.password_changed = true`。 + --- ## 二、统一约定 @@ -635,6 +671,7 @@ Base 前缀 `/api/v1/admin`。 | `GET` | `/api/v1/platform/employees` | 列全平台未软删员工(cursor 分页 + `q` 搜索,见 §7.0) | | `POST` | `/api/v1/platform/employees` | 创建平台员工账号(返回 201);不自动加入任何 workspace | | `PATCH` | `/api/v1/platform/employees/{user_id}` | 改员工资料/状态/平台角色(仅系统管理员) | +| `POST` | `/api/v1/platform/employees/{user_id}/reset-password` | 重置员工密码(仅系统管理员;不需要旧密码) | | `DELETE` | `/api/v1/platform/employees/{user_id}` | 软删员工;级联软删其 workspace 成员关系(仅系统管理员) | | `GET` | `/api/v1/platform/workspaces` | 列 workspace(`active`/`archived`)(cursor 分页 + `q` 搜索,见 §7.0) | | `POST` | `/api/v1/platform/workspaces` | 创建 workspace(返回 201);创建者自动成为 admin 成员 | @@ -864,6 +901,19 @@ Base 前缀 `/api/v1/admin`。 - **响应 200**:返回更新后的 `PlatformEmployeePayload`,`role_code` / `role_name` 反映最新的 `platform_role_id`。 +### 7.10.1 `POST /api/v1/platform/employees/{user_id}/reset-password` + +系统管理员为指定员工设置新密码(不校验旧密码)。JWT 无服务端吊销列表,已有会话在过期前仍可用;对方下次登录须用新密码。 + +- **请求体**: + +| 字段 | 类型 | 限制 | 说明 | +|---|---|---|---| +| `new_password` | string | 8~72 | 新密码;bcrypt 哈希后写入 `password_hash` | + +- 目标不存在或已软删 → 404 "用户不存在"。 +- **响应 200**:`data = { "user_id": "...", "password_reset": true }`。 + ### 7.11 `DELETE /api/v1/platform/employees/{user_id}` 软删除平台员工;级联软删其所有 `workspace_members` 行。调用者必须是系统管理员。 diff --git a/backend/src/backend/api/auth.py b/backend/src/backend/api/auth.py index 4870385..74ebf8e 100644 --- a/backend/src/backend/api/auth.py +++ b/backend/src/backend/api/auth.py @@ -12,6 +12,8 @@ The user-facing flow is: ``backend.api.dependencies.request_context`` 3. POST /api/v1/auth/logout — clear the cookie 4. GET /api/v1/auth/me — return the current user + 5. PATCH /api/v1/auth/me — update display_name / email + 6. POST /api/v1/auth/password — change password (clears session cookie) Service-to-service calls do not use these endpoints — they live on the shared Docker network and have no application-layer auth. See @@ -24,15 +26,16 @@ from typing import Any from common.auth.jwt import JwtError, issue_jwt, verify_jwt_token from common.auth.membership import resolve_is_system_admin -from common.auth.passwords import verify_password +from common.auth.passwords import hash_password, verify_password from common.config import settings from common.db.models import Roles, Users, WorkspaceMembers, Workspaces from common.ids import new_ulid from fastapi import APIRouter, Depends, HTTPException, Request, Response, status +from pydantic import BaseModel, ConfigDict, Field from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from backend.api.dependencies import database_session, load_user_permissions +from backend.api.dependencies import current_user, database_session, load_user_permissions router = APIRouter(prefix="/api/v1/auth", tags=["auth"]) @@ -95,6 +98,29 @@ def _workspace_payload( } +class ProfileUpdate(BaseModel): + model_config = ConfigDict(extra="forbid") + + display_name: str | None = Field(default=None, min_length=1, max_length=100) + email: str | None = Field(default=None, max_length=255) + + +class PasswordChange(BaseModel): + model_config = ConfigDict(extra="forbid") + + current_password: str = Field(min_length=1, max_length=72) + new_password: str = Field(min_length=8, max_length=72) + + +async def _platform_role_code(session: AsyncSession, user: Users) -> str | None: + if user.platform_role_id is None: + return None + platform_role_row = await session.scalar( + select(Roles).where(Roles.role_id == user.platform_role_id) + ) + return platform_role_row.role_code if platform_role_row is not None else None + + # 校验账号密码,设置登录 Cookie,并返回用户可进入的工作区列表。 @router.post("/login") async def login( @@ -167,13 +193,7 @@ async def login( # (``Users.platform_role_id``); the iteration over rows above was # a legacy way to find the "highest" workspace role and is no # longer correct now that admin/developer are platform-only. - user_role_code: str | None = None - if user.platform_role_id is not None: - platform_role_row = await session.scalar( - select(Roles).where(Roles.role_id == user.platform_role_id) - ) - if platform_role_row is not None: - user_role_code = platform_role_row.role_code + user_role_code = await _platform_role_code(session, user) token = issue_jwt(user.user_id, ttl_seconds=COOKIE_TTL_SECONDS) _set_session_cookie(request, response, token) @@ -257,13 +277,7 @@ async def me( workspaces = [_workspace_payload(ws, role) for ws, role, _ in rows] default_workspace_id = workspaces[0]["workspace_id"] if workspaces else None - user_role_code: str | None = None - if user.platform_role_id is not None: - platform_role_row = await session.scalar( - select(Roles).where(Roles.role_id == user.platform_role_id) - ) - if platform_role_row is not None: - user_role_code = platform_role_row.role_code + user_role_code = await _platform_role_code(session, user) is_system_admin = await resolve_is_system_admin(session, user) permissions = await load_user_permissions(session, user) @@ -284,6 +298,86 @@ async def me( } +# 当前登录用户修改显示名 / 邮箱(不可改 username、角色、密码)。 +@router.patch("/me") +async def update_me( + payload: ProfileUpdate, + user: Users = Depends(current_user), + session: AsyncSession = Depends(database_session), +) -> dict[str, Any]: + """Update the authenticated user's display_name and/or email.""" + if payload.display_name is None and payload.email is None: + raise HTTPException( + status.HTTP_400_BAD_REQUEST, + "至少提供一个可修改字段", + ) + + if payload.display_name is not None: + user.display_name = payload.display_name.strip() + + if payload.email is not None: + normalized = payload.email.strip() or None + if normalized is not None: + duplicate = await session.scalar( + select(Users.user_id).where( + Users.email == normalized, + Users.user_id != user.user_id, + Users.is_deleted == 0, + ) + ) + if duplicate is not None: + raise HTTPException(status.HTTP_409_CONFLICT, "邮箱已存在") + user.email = normalized + + await session.flush() + await session.refresh(user) + + user_role_code = await _platform_role_code(session, user) + is_system_admin = await resolve_is_system_admin(session, user) + permissions = await load_user_permissions(session, user) + + return { + "request_id": new_ulid(), + "data": { + "user": _user_payload( + user, + user_role_code, + is_system_admin=is_system_admin, + permissions=permissions, + ), + }, + "meta": {}, + } + + +# 当前登录用户修改密码;成功后清除 Cookie,需重新登录。 +@router.post("/password") +async def change_password( + payload: PasswordChange, + response: Response, + user: Users = Depends(current_user), + session: AsyncSession = Depends(database_session), +) -> dict[str, Any]: + """Change password after verifying the current one; clears the session cookie.""" + if not verify_password(payload.current_password, user.password_hash): + raise HTTPException(status.HTTP_400_BAD_REQUEST, "当前密码不正确") + if payload.current_password == payload.new_password: + raise HTTPException( + status.HTTP_400_BAD_REQUEST, + "新密码不能与当前密码相同", + ) + + user.password_hash = hash_password(payload.new_password) + await session.flush() + _clear_session_cookie(response) + + return { + "request_id": new_ulid(), + "data": {"password_changed": True}, + "meta": {}, + } + + __all__ = [ "COOKIE_NAME", "COOKIE_TTL_SECONDS", diff --git a/backend/src/backend/api/platform/employees.py b/backend/src/backend/api/platform/employees.py index 0e2db9a..6be9e69 100644 --- a/backend/src/backend/api/platform/employees.py +++ b/backend/src/backend/api/platform/employees.py @@ -396,3 +396,30 @@ async def delete_platform_employee( ) +class PlatformEmployeePasswordReset(BaseModel): + model_config = ConfigDict(extra="forbid") + + new_password: str = Field(min_length=8, max_length=72) + + +# 系统管理员重置指定员工密码(不需要旧密码)。 +@router.post("/employees/{user_id}/reset-password") +async def reset_platform_employee_password( + user_id: str, + payload: PlatformEmployeePasswordReset, + context: SystemAdminContext = Depends(system_admin_context), + session: AsyncSession = Depends(database_session), +) -> dict[str, Any]: + """Reset a platform employee's password. Caller must be a system admin.""" + user = await session.get(Users, user_id) + if user is None or user.is_deleted != 0: + raise HTTPException(status.HTTP_404_NOT_FOUND, "用户不存在") + + user.password_hash = hash_password(payload.new_password) + await session.flush() + return _envelope( + context.request_id, + {"user_id": user_id, "password_reset": True}, + ) + + diff --git a/backend/tests/test_auth_profile_password.py b/backend/tests/test_auth_profile_password.py new file mode 100644 index 0000000..fa69bb1 --- /dev/null +++ b/backend/tests/test_auth_profile_password.py @@ -0,0 +1,39 @@ +"""Unit tests for auth profile / password request schemas.""" + +from __future__ import annotations + +import pytest +from pydantic import ValidationError + +from backend.api.auth import PasswordChange, ProfileUpdate +from backend.api.platform.employees import PlatformEmployeePasswordReset + + +def test_profile_update_allows_partial_fields() -> None: + only_name = ProfileUpdate(display_name="张三") + assert only_name.display_name == "张三" + assert only_name.email is None + + only_email = ProfileUpdate(email="a@example.com") + assert only_email.email == "a@example.com" + + +def test_profile_update_forbids_unknown_fields() -> None: + with pytest.raises(ValidationError): + ProfileUpdate(display_name="张三", username="hacked") # type: ignore[call-arg] + + +def test_password_change_enforces_new_password_length() -> None: + with pytest.raises(ValidationError): + PasswordChange(current_password="old-pass", new_password="short") + + ok = PasswordChange(current_password="old-pass-1", new_password="new-pass-12") + assert ok.new_password == "new-pass-12" + + +def test_admin_password_reset_schema() -> None: + with pytest.raises(ValidationError): + PlatformEmployeePasswordReset(new_password="1234567") + + payload = PlatformEmployeePasswordReset(new_password="reset-pass-9") + assert payload.new_password == "reset-pass-9" diff --git a/frontend/.gitignore b/frontend/.gitignore index b4ac379..2f75b43 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -6,6 +6,9 @@ /.react-router/ /build/ +# Vite / file-viewer 构建产物,不要提交 +/dist/ + # monaco-editor 预构建 min/vs(由 scripts/copy-monaco.mjs 在 postinstall 时 # 从 node_modules/monaco-editor/min/vs 复制生成,不要提交) /public/monaco/vs/ diff --git a/frontend/app/components/common/AccountSettingsDialog.tsx b/frontend/app/components/common/AccountSettingsDialog.tsx new file mode 100644 index 0000000..4b3f81a --- /dev/null +++ b/frontend/app/components/common/AccountSettingsDialog.tsx @@ -0,0 +1,269 @@ +import { useEffect, useState } from "react"; +import { toast } from "sonner"; + +import { useAuth } from "~/context/AuthContext"; +import { Button } from "~/components/ui/button"; +import { Input } from "~/components/ui/input"; +import { + formFieldClass, + formHintClass, + formInputClass, + modalFormClass, +} from "~/features/platform/modalUi"; +import { cn } from "~/lib/utils"; + +import { + AppFormDialog, + dialogPrimaryButtonClass, + dialogSecondaryButtonClass, +} from "./AppFormDialog"; + +type AccountSettingsDialogProps = { + open: boolean; + onOpenChange: (open: boolean) => void; +}; + +type SettingsTab = "profile" | "password"; + +const TAB_ITEMS: Array<{ id: SettingsTab; label: string }> = [ + { id: "profile", label: "基本信息" }, + { id: "password", label: "修改密码" }, +]; + +export function AccountSettingsDialog({ + open, + onOpenChange, +}: AccountSettingsDialogProps) { + const { user, updateProfile, changePassword, logout } = useAuth(); + const [tab, setTab] = useState("profile"); + const [displayName, setDisplayName] = useState(""); + const [email, setEmail] = useState(""); + const [savingProfile, setSavingProfile] = useState(false); + const [currentPassword, setCurrentPassword] = useState(""); + const [newPassword, setNewPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [savingPassword, setSavingPassword] = useState(false); + + useEffect(() => { + if (!open || !user) return; + setTab("profile"); + setDisplayName(user.display_name); + setEmail(user.email ?? ""); + setCurrentPassword(""); + setNewPassword(""); + setConfirmPassword(""); + }, [open, user]); + + const handleSaveProfile = async (event: React.FormEvent) => { + event.preventDefault(); + const trimmedName = displayName.trim(); + if (!trimmedName) { + toast.error("显示名称不能为空"); + return; + } + setSavingProfile(true); + try { + await updateProfile({ + display_name: trimmedName, + email: email.trim(), + }); + toast.success("资料已更新"); + onOpenChange(false); + } catch (error) { + toast.error(error instanceof Error ? error.message : "资料更新失败"); + } finally { + setSavingProfile(false); + } + }; + + const handleChangePassword = async (event: React.FormEvent) => { + event.preventDefault(); + if (!currentPassword) { + toast.error("请输入当前密码"); + return; + } + if (newPassword.length < 8 || newPassword.length > 72) { + toast.error("新密码长度需为 8~72 字符"); + return; + } + if (newPassword !== confirmPassword) { + toast.error("两次输入的新密码不一致"); + return; + } + if (newPassword === currentPassword) { + toast.error("新密码不能与当前密码相同"); + return; + } + setSavingPassword(true); + try { + await changePassword({ + current_password: currentPassword, + new_password: newPassword, + }); + toast.success("密码已修改,请重新登录"); + onOpenChange(false); + await logout(); + } catch (error) { + toast.error(error instanceof Error ? error.message : "密码修改失败"); + } finally { + setSavingPassword(false); + } + }; + + const formId = + tab === "profile" ? "account-profile-form" : "account-password-form"; + const saving = tab === "profile" ? savingProfile : savingPassword; + const primaryLabel = + tab === "profile" + ? savingProfile + ? "保存中…" + : "保存资料" + : savingPassword + ? "修改中…" + : "修改密码"; + + return ( + + + + + } + > +
+
+ {TAB_ITEMS.map((item) => { + const active = tab === item.id; + return ( + + ); + })} +
+
+ +
+ {tab === "profile" ? ( +
void handleSaveProfile(event)} + > + + + +
+ ) : ( +
void handleChangePassword(event)} + > + + + +
+ )} +
+
+ ); +} diff --git a/frontend/app/components/common/Topbar.tsx b/frontend/app/components/common/Topbar.tsx index 04ac5d5..7f3ee73 100644 --- a/frontend/app/components/common/Topbar.tsx +++ b/frontend/app/components/common/Topbar.tsx @@ -1,5 +1,7 @@ -import { ChevronRight, LayoutGrid } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; +import { ChevronRight, LayoutGrid, LogOut, Settings } from "lucide-react"; import type { AuthUser, AuthWorkspace } from "../../context/AuthContext"; +import { AccountSettingsDialog } from "./AccountSettingsDialog"; type TopbarProps = { pageTitle: string; @@ -24,6 +26,26 @@ export function Topbar({ onSetCurrentWorkspace, onLogout, }: TopbarProps) { + const [userMenuOpen, setUserMenuOpen] = useState(false); + const [settingsOpen, setSettingsOpen] = useState(false); + const userMenuRef = useRef(null); + + useEffect(() => { + if (!userMenuOpen) return; + const onPointerDown = (event: MouseEvent) => { + if (!userMenuRef.current?.contains(event.target as Node)) { + setUserMenuOpen(false); + } + }; + document.addEventListener("mousedown", onPointerDown); + return () => document.removeEventListener("mousedown", onPointerDown); + }, [userMenuOpen]); + + const openUserMenu = () => { + onSetWorkspaceMenuOpen(false); + setUserMenuOpen((open) => !open); + }; + return (
@@ -57,7 +79,10 @@ export function Topbar({
)} -
+
- + {userMenuOpen && ( +
+
+ + {user?.display_name ?? "未知用户"} + + + {user?.username} + {user?.role_code === "admin" ? " · 管理员" : " · 开发人员"} + +
+ + +
+ )}
+ +
); } diff --git a/frontend/app/context/AuthContext.tsx b/frontend/app/context/AuthContext.tsx index c79a2dd..39b71a8 100644 --- a/frontend/app/context/AuthContext.tsx +++ b/frontend/app/context/AuthContext.tsx @@ -48,6 +48,14 @@ type AuthContextValue = { loading: boolean; login: (username: string, password: string) => Promise; logout: () => Promise; + updateProfile: (input: { + display_name?: string; + email?: string; + }) => Promise; + changePassword: (input: { + current_password: string; + new_password: string; + }) => Promise; setCurrentWorkspace: (workspaceId: string) => void; refreshWorkspaces: () => Promise; }; @@ -162,6 +170,27 @@ export function AuthProvider({ children }: { children: ReactNode }) { } }, [navigate]); + const updateProfile = useCallback(async (input: { + display_name?: string; + email?: string; + }) => { + const result = await authRequest<{ user: AuthUser }>("/api/v1/auth/me", { + method: "PATCH", + body: JSON.stringify(input), + }); + setUser(result.user); + }, []); + + const changePassword = useCallback(async (input: { + current_password: string; + new_password: string; + }) => { + await authRequest<{ password_changed: boolean }>("/api/v1/auth/password", { + method: "POST", + body: JSON.stringify(input), + }); + }, []); + const setCurrentWorkspace = useCallback((workspaceId: string) => { const workspace = workspaces.find((item) => item.workspace_id === workspaceId); if (!workspace) return; @@ -193,9 +222,22 @@ export function AuthProvider({ children }: { children: ReactNode }) { loading, login, logout, + updateProfile, + changePassword, setCurrentWorkspace, refreshWorkspaces, - }), [currentWorkspace, loading, login, logout, setCurrentWorkspace, user, workspaces, refreshWorkspaces]); + }), [ + changePassword, + currentWorkspace, + loading, + login, + logout, + refreshWorkspaces, + setCurrentWorkspace, + updateProfile, + user, + workspaces, + ]); if (loading) { return ( @@ -283,6 +325,8 @@ export function useApi(): WorkspaceBoundApi { rawApi.updatePlatformEmployee(userId, input), deleteEmployee: (userId) => rawApi.deleteEmployee(workspaceId, userId), deletePlatformEmployee: (userId) => rawApi.deletePlatformEmployee(userId), + resetPlatformEmployeePassword: (userId, newPassword) => + rawApi.resetPlatformEmployeePassword(userId, newPassword), // Role Management - 角色管理接口(跨 workspace,不需要传入 workspaceId) listPlatformRoles: () => rawApi.listPlatformRoles(), getPlatformRole: (roleCode) => rawApi.getPlatformRole(roleCode), diff --git a/frontend/app/features/admin/ResetPasswordDialog.tsx b/frontend/app/features/admin/ResetPasswordDialog.tsx new file mode 100644 index 0000000..261a1ee --- /dev/null +++ b/frontend/app/features/admin/ResetPasswordDialog.tsx @@ -0,0 +1,127 @@ +import { useEffect, useState } from "react"; + +import { + AppFormDialog, + dialogPrimaryButtonClass, + dialogSecondaryButtonClass, +} from "~/components/common/AppFormDialog"; +import { Button } from "~/components/ui/button"; +import { Input } from "~/components/ui/input"; +import { + formFieldClass, + formHintClass, + formInputClass, + modalFormClass, +} from "../platform/modalUi"; + +type ResetPasswordDialogProps = { + open: boolean; + displayName: string; + username: string; + saving: boolean; + onSubmit: (newPassword: string) => Promise; + onClose: () => void; +}; + +export function ResetPasswordDialog({ + open, + displayName, + username, + saving, + onSubmit, + onClose, +}: ResetPasswordDialogProps) { + const [password, setPassword] = useState(""); + const [confirm, setConfirm] = useState(""); + const [error, setError] = useState(null); + + useEffect(() => { + if (!open) return; + setPassword(""); + setConfirm(""); + setError(null); + }, [open]); + + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + if (password.length < 8 || password.length > 72) { + setError("密码长度需为 8~72 字符"); + return; + } + if (password !== confirm) { + setError("两次输入的密码不一致"); + return; + } + setError(null); + await onSubmit(password); + }; + + return ( + { + if (!nextOpen) onClose(); + }} + eyebrow="SECURITY" + title="重置密码" + > +
void handleSubmit(event)} + > +

+ 正在为 {displayName} + ({username})设置新密码。对方需使用新密码重新登录。 +

+ + + {error ? ( +

{error}

+ ) : null} +
+ + +
+
+
+ ); +} diff --git a/frontend/app/features/admin/UserFormDialog.tsx b/frontend/app/features/admin/UserFormDialog.tsx index 3fac2de..12ad2c3 100644 --- a/frontend/app/features/admin/UserFormDialog.tsx +++ b/frontend/app/features/admin/UserFormDialog.tsx @@ -9,6 +9,7 @@ import { Input } from "~/components/ui/input"; import { CreateUserProjectField } from "./CreateUserProjectField"; import { formFieldClass, + formHintClass, formInputClass, modalFormClass, } from "../platform/modalUi"; @@ -40,6 +41,7 @@ export function UserFormDialog({ onToggleWorkspace, onSubmit, onClose, + onResetPassword, }: { open: boolean; editing: Employee | null; @@ -55,6 +57,7 @@ export function UserFormDialog({ onToggleWorkspace: (workspaceId: string) => void; onSubmit: (event: React.FormEvent) => void; onClose: () => void; + onResetPassword?: () => void; }) { return ( )} + {editing && onResetPassword ? ( +
+
安全
+

+ 该用户忘记密码时,可为其设置临时密码。对方需使用新密码重新登录。 +

+ +
+ ) : null}