feat:账号设置+密码重置
This commit is contained in:
@@ -50,6 +50,42 @@
|
||||
> - `is_system_admin: bool` —— 派生自 `users.platform_role_id` 指向的角色 `role_code == 'admin'` 且用户状态为 `active`。前端据此决定是否渲染"系统管理"入口。
|
||||
> - `permissions: string[]` —— 当前用户通过其平台角色(`platform_role_id`)间接持有的菜单权限码列表(`permission_code`),按字典序排列;未分配平台角色时为空数组。前端据此过滤菜单与 `<RequirePermission>` 路由守卫。**仅控制前端展示,不参与后端 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` 行。调用者必须是系统管理员。
|
||||
|
||||
+110
-16
@@ -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",
|
||||
|
||||
@@ -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},
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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"
|
||||
@@ -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/
|
||||
|
||||
@@ -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<SettingsTab>("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 (
|
||||
<AppFormDialog
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
eyebrow="ACCOUNT"
|
||||
title="账号设置"
|
||||
size="panel"
|
||||
showCloseButton
|
||||
footer={
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className={dialogSecondaryButtonClass}
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={saving}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
form={formId}
|
||||
disabled={saving}
|
||||
className={dialogPrimaryButtonClass}
|
||||
>
|
||||
{primaryLabel}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="border-b border-line px-[22px]">
|
||||
<div className="flex gap-1" role="tablist" aria-label="账号设置分区">
|
||||
{TAB_ITEMS.map((item) => {
|
||||
const active = tab === item.id;
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={active}
|
||||
className={cn(
|
||||
"relative -mb-px cursor-pointer border-0 border-b-2 bg-transparent px-3 py-2.5 text-[12px] font-[650] outline-none transition-colors",
|
||||
active
|
||||
? "border-[#2d82d4] text-[#1c5fa8]"
|
||||
: "border-transparent text-[#6b7c8f] hover:text-[#3c4e62]",
|
||||
)}
|
||||
onClick={() => setTab(item.id)}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`${modalFormClass} pb-5`}>
|
||||
{tab === "profile" ? (
|
||||
<form
|
||||
id={formId}
|
||||
onSubmit={(event) => void handleSaveProfile(event)}
|
||||
>
|
||||
<label className={formFieldClass}>
|
||||
<span>登录账号</span>
|
||||
<Input
|
||||
className={formInputClass}
|
||||
value={user?.username ?? ""}
|
||||
disabled
|
||||
readOnly
|
||||
/>
|
||||
<span className={formHintClass}>登录账号创建后不可修改</span>
|
||||
</label>
|
||||
<label className={formFieldClass}>
|
||||
<span>
|
||||
显示名称<span className="text-danger">*</span>
|
||||
</span>
|
||||
<Input
|
||||
className={formInputClass}
|
||||
value={displayName}
|
||||
onChange={(event) => setDisplayName(event.target.value)}
|
||||
placeholder="请输入显示名称"
|
||||
autoComplete="name"
|
||||
autoFocus
|
||||
/>
|
||||
</label>
|
||||
<label className={formFieldClass}>
|
||||
<span>邮箱</span>
|
||||
<Input
|
||||
className={formInputClass}
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(event) => setEmail(event.target.value)}
|
||||
placeholder="可选"
|
||||
autoComplete="email"
|
||||
/>
|
||||
</label>
|
||||
</form>
|
||||
) : (
|
||||
<form
|
||||
id={formId}
|
||||
onSubmit={(event) => void handleChangePassword(event)}
|
||||
>
|
||||
<label className={formFieldClass}>
|
||||
<span>
|
||||
当前密码<span className="text-danger">*</span>
|
||||
</span>
|
||||
<Input
|
||||
className={formInputClass}
|
||||
type="password"
|
||||
value={currentPassword}
|
||||
onChange={(event) => setCurrentPassword(event.target.value)}
|
||||
autoComplete="current-password"
|
||||
autoFocus
|
||||
/>
|
||||
</label>
|
||||
<label className={formFieldClass}>
|
||||
<span>
|
||||
新密码<span className="text-danger">*</span>
|
||||
</span>
|
||||
<Input
|
||||
className={formInputClass}
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={(event) => setNewPassword(event.target.value)}
|
||||
autoComplete="new-password"
|
||||
placeholder="8~72 字符"
|
||||
/>
|
||||
</label>
|
||||
<label className={formFieldClass}>
|
||||
<span>
|
||||
确认新密码<span className="text-danger">*</span>
|
||||
</span>
|
||||
<Input
|
||||
className={formInputClass}
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(event) => setConfirmPassword(event.target.value)}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</label>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</AppFormDialog>
|
||||
);
|
||||
}
|
||||
@@ -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<HTMLDivElement>(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 (
|
||||
<header className="relative z-[2] flex h-[70px] min-h-[70px] items-center justify-between border-b border-[#dfe6ee] bg-white px-[22px]">
|
||||
<div className="flex items-center gap-[9px]">
|
||||
@@ -57,7 +79,10 @@ export function Topbar({
|
||||
<button
|
||||
className="flex h-[46px] cursor-pointer items-center gap-[9px] rounded-lg border border-line bg-white px-[11px] hover:border-[#b9cce0]"
|
||||
type="button"
|
||||
onClick={() => onSetWorkspaceMenuOpen(!workspaceMenuOpen)}
|
||||
onClick={() => {
|
||||
setUserMenuOpen(false);
|
||||
onSetWorkspaceMenuOpen(!workspaceMenuOpen);
|
||||
}}
|
||||
>
|
||||
<span className="grid size-[30px] place-items-center rounded-[7px] bg-gradient-to-br from-[#23aa73] to-[#158d5d] text-white">
|
||||
<LayoutGrid size={18} />
|
||||
@@ -104,10 +129,13 @@ export function Topbar({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="relative flex items-center gap-[9px]">
|
||||
<div className="relative" ref={userMenuRef}>
|
||||
<button
|
||||
className="flex h-[46px] cursor-pointer items-center gap-[9px] rounded-lg border border-transparent bg-white px-[11px] hover:border-[#b9cce0]"
|
||||
type="button"
|
||||
aria-expanded={userMenuOpen}
|
||||
aria-haspopup="menu"
|
||||
onClick={openUserMenu}
|
||||
>
|
||||
<span className="grid size-[34px] place-items-center rounded-full bg-gradient-to-br from-[#3b92ed] to-[#1869c9] text-sm font-bold text-white">
|
||||
{user?.display_name?.slice(0, 1) ?? "?"}
|
||||
@@ -120,18 +148,52 @@ export function Topbar({
|
||||
{user?.role_code === "admin" ? "管理员" : "开发人员"}
|
||||
</small>
|
||||
</span>
|
||||
<ChevronRight size={15} className="text-[#97a3b1]" />
|
||||
</button>
|
||||
{userMenuOpen && (
|
||||
<div
|
||||
className="absolute top-[calc(100%+7px)] right-0 z-50 w-[240px] rounded-lg border border-line bg-white p-1.5 shadow-lg"
|
||||
role="menu"
|
||||
>
|
||||
<div className="border-b border-line px-2.5 py-2.5">
|
||||
<strong className="block truncate text-[12px] text-[#243a50]">
|
||||
{user?.display_name ?? "未知用户"}
|
||||
</strong>
|
||||
<small className="mt-0.5 block truncate text-[10px] text-[#8797a7]">
|
||||
{user?.username}
|
||||
{user?.role_code === "admin" ? " · 管理员" : " · 开发人员"}
|
||||
</small>
|
||||
</div>
|
||||
<button
|
||||
className="mt-1 flex w-full cursor-pointer items-center gap-2 rounded-md border-0 bg-transparent px-2.5 py-2 text-left text-[12px] text-[#40576d] hover:bg-brand-soft"
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
setUserMenuOpen(false);
|
||||
setSettingsOpen(true);
|
||||
}}
|
||||
>
|
||||
<Settings size={14} />
|
||||
账号设置
|
||||
</button>
|
||||
<button
|
||||
className="inline-flex cursor-pointer items-center gap-0.5 border-0 bg-transparent px-px py-[5px] text-xs font-semibold text-[#1474d4] hover:text-[#0d58a2]"
|
||||
className="flex w-full cursor-pointer items-center gap-2 rounded-md border-0 bg-transparent px-2.5 py-2 text-left text-[12px] text-[#c14a4a] hover:bg-[#fdf2f2]"
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
setUserMenuOpen(false);
|
||||
void onLogout();
|
||||
}}
|
||||
>
|
||||
<LogOut size={14} />
|
||||
登出
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AccountSettingsDialog open={settingsOpen} onOpenChange={setSettingsOpen} />
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -48,6 +48,14 @@ type AuthContextValue = {
|
||||
loading: boolean;
|
||||
login: (username: string, password: string) => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
updateProfile: (input: {
|
||||
display_name?: string;
|
||||
email?: string;
|
||||
}) => Promise<void>;
|
||||
changePassword: (input: {
|
||||
current_password: string;
|
||||
new_password: string;
|
||||
}) => Promise<void>;
|
||||
setCurrentWorkspace: (workspaceId: string) => void;
|
||||
refreshWorkspaces: () => Promise<void>;
|
||||
};
|
||||
@@ -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),
|
||||
|
||||
@@ -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<void>;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export function ResetPasswordDialog({
|
||||
open,
|
||||
displayName,
|
||||
username,
|
||||
saving,
|
||||
onSubmit,
|
||||
onClose,
|
||||
}: ResetPasswordDialogProps) {
|
||||
const [password, setPassword] = useState("");
|
||||
const [confirm, setConfirm] = useState("");
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<AppFormDialog
|
||||
open={open}
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (!nextOpen) onClose();
|
||||
}}
|
||||
eyebrow="SECURITY"
|
||||
title="重置密码"
|
||||
>
|
||||
<form
|
||||
className={`${modalFormClass} pb-5`}
|
||||
onSubmit={(event) => void handleSubmit(event)}
|
||||
>
|
||||
<p className="mb-4 text-[12px] text-[#587087]">
|
||||
正在为 <strong className="text-[#23384e]">{displayName}</strong>
|
||||
({username})设置新密码。对方需使用新密码重新登录。
|
||||
</p>
|
||||
<label className={formFieldClass}>
|
||||
<span>
|
||||
新密码<span className="text-danger">*</span>
|
||||
</span>
|
||||
<Input
|
||||
className={formInputClass}
|
||||
type="password"
|
||||
autoFocus
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
autoComplete="new-password"
|
||||
placeholder="8~72 字符"
|
||||
/>
|
||||
</label>
|
||||
<label className={formFieldClass}>
|
||||
<span>
|
||||
确认新密码<span className="text-danger">*</span>
|
||||
</span>
|
||||
<Input
|
||||
className={formInputClass}
|
||||
type="password"
|
||||
value={confirm}
|
||||
onChange={(event) => setConfirm(event.target.value)}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<span className={formHintClass}>重置后不会强制踢下线已有会话</span>
|
||||
</label>
|
||||
{error ? (
|
||||
<p className="mb-3 text-[11px] text-danger">{error}</p>
|
||||
) : null}
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className={dialogSecondaryButtonClass}
|
||||
onClick={onClose}
|
||||
disabled={saving}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
className={dialogPrimaryButtonClass}
|
||||
disabled={saving}
|
||||
>
|
||||
{saving ? "重置中…" : "确认重置"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</AppFormDialog>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<AppFormDialog
|
||||
@@ -189,6 +192,23 @@ export function UserFormDialog({
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
{editing && onResetPassword ? (
|
||||
<div className="mb-4 rounded-md border border-[#e6edf3] bg-[#f8fafb] px-3.5 py-3">
|
||||
<div className="text-[12px] font-semibold text-[#3c4e62]">安全</div>
|
||||
<p className={`${formHintClass} mt-1.5 mb-2.5 leading-relaxed`}>
|
||||
该用户忘记密码时,可为其设置临时密码。对方需使用新密码重新登录。
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={dialogSecondaryButtonClass}
|
||||
onClick={onResetPassword}
|
||||
>
|
||||
重置密码
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="mt-[3px] flex shrink-0 justify-end gap-2 border-t border-line bg-white -mx-[22px] px-[22px] py-3.5">
|
||||
<Button
|
||||
variant="outline"
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
import { AdminColgroup, USER_PROJECT_COL_WIDTHS } from "./AdminTable";
|
||||
import { AdminPagination } from "./AdminPagination";
|
||||
import { UserFormDialog, type UserFormState } from "./UserFormDialog";
|
||||
import { ResetPasswordDialog } from "./ResetPasswordDialog";
|
||||
import { useCursorPage } from "./useCursorPage";
|
||||
import {
|
||||
adminEmptyClass,
|
||||
@@ -61,6 +62,8 @@ export function UserManagementPage({
|
||||
const [form, setForm] = useState<UserFormState>(EMPTY_FORM);
|
||||
const [userSearchTerm, setUserSearchTerm] = useState("");
|
||||
const [deleteTarget, setDeleteTarget] = useState<Employee | null>(null);
|
||||
const [resetTarget, setResetTarget] = useState<Employee | null>(null);
|
||||
const [resetSaving, setResetSaving] = useState(false);
|
||||
const [createProjects, setCreateProjects] = useState<Workspace[]>([]);
|
||||
const [createProjectsLoading, setCreateProjectsLoading] = useState(false);
|
||||
const [selectedWorkspaceIds, setSelectedWorkspaceIds] = useState<string[]>([]);
|
||||
@@ -262,6 +265,23 @@ export function UserManagementPage({
|
||||
}
|
||||
};
|
||||
|
||||
const executeResetPassword = async (newPassword: string): Promise<void> => {
|
||||
if (!resetTarget) return;
|
||||
setResetSaving(true);
|
||||
try {
|
||||
await api.resetPlatformEmployeePassword(resetTarget.user_id, newPassword);
|
||||
onNotify({ tone: "success", message: "密码已重置" });
|
||||
setResetTarget(null);
|
||||
} catch (error) {
|
||||
onNotify({
|
||||
tone: "error",
|
||||
message: error instanceof Error ? error.message : "重置密码失败",
|
||||
});
|
||||
} finally {
|
||||
setResetSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const emptyMessage = useMemo(() => {
|
||||
if (loading) return "正在加载用户…";
|
||||
if (debouncedSearch.trim()) return "未找到匹配的用户";
|
||||
@@ -379,6 +399,14 @@ export function UserManagementPage({
|
||||
onToggleWorkspace={toggleCreateWorkspace}
|
||||
onSubmit={(event) => void submit(event)}
|
||||
onClose={() => setDialogOpen(false)}
|
||||
onResetPassword={
|
||||
editing
|
||||
? () => {
|
||||
setResetTarget(editing);
|
||||
setDialogOpen(false);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
@@ -400,6 +428,15 @@ export function UserManagementPage({
|
||||
setDeleteTarget(null);
|
||||
}}
|
||||
/>
|
||||
|
||||
<ResetPasswordDialog
|
||||
open={resetTarget !== null}
|
||||
displayName={resetTarget?.display_name ?? ""}
|
||||
username={resetTarget?.username ?? ""}
|
||||
saving={resetSaving}
|
||||
onSubmit={executeResetPassword}
|
||||
onClose={() => setResetTarget(null)}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -267,6 +267,10 @@ export type WorkspaceBoundApi = {
|
||||
deletePlatformEmployee: (
|
||||
userId: string,
|
||||
) => Promise<{ user_id: string; deleted: boolean }>;
|
||||
resetPlatformEmployeePassword: (
|
||||
userId: string,
|
||||
newPassword: string,
|
||||
) => Promise<{ user_id: string; password_reset: boolean }>;
|
||||
listPlatformRoles: () => Promise<Role[]>;
|
||||
getPlatformRole: (roleCode: string) => Promise<Role>;
|
||||
listPlatformPermissions: () => Promise<PlatformPermission[]>;
|
||||
|
||||
@@ -58,3 +58,13 @@ export async function deletePlatformEmployee(
|
||||
);
|
||||
}
|
||||
|
||||
export async function resetPlatformEmployeePassword(
|
||||
userId: string,
|
||||
newPassword: string,
|
||||
): Promise<{ user_id: string; password_reset: boolean }> {
|
||||
return apiRequest(
|
||||
`/api/v1/platform/employees/${userId}/reset-password`,
|
||||
{ method: "POST", body: JSON.stringify({ new_password: newPassword }) },
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user