feat:账号设置+密码重置

This commit is contained in:
xiaozhu
2026-09-02 10:10:41 +08:00
committed by tao.chen
parent 856bdbae8d
commit 5f5eb739c2
13 changed files with 815 additions and 29 deletions
+50
View File
@@ -50,6 +50,42 @@
> - `is_system_admin: bool` —— 派生自 `users.platform_role_id` 指向的角色 `role_code == 'admin'` 且用户状态为 `active`。前端据此决定是否渲染"系统管理"入口。 > - `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。 > - `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 | | `GET` | `/api/v1/platform/employees` | 列全平台未软删员工(cursor 分页 + `q` 搜索,见 §7.0 |
| `POST` | `/api/v1/platform/employees` | 创建平台员工账号(返回 201);不自动加入任何 workspace | | `POST` | `/api/v1/platform/employees` | 创建平台员工账号(返回 201);不自动加入任何 workspace |
| `PATCH` | `/api/v1/platform/employees/{user_id}` | 改员工资料/状态/平台角色(仅系统管理员) | | `PATCH` | `/api/v1/platform/employees/{user_id}` | 改员工资料/状态/平台角色(仅系统管理员) |
| `POST` | `/api/v1/platform/employees/{user_id}/reset-password` | 重置员工密码(仅系统管理员;不需要旧密码) |
| `DELETE` | `/api/v1/platform/employees/{user_id}` | 软删员工;级联软删其 workspace 成员关系(仅系统管理员) | | `DELETE` | `/api/v1/platform/employees/{user_id}` | 软删员工;级联软删其 workspace 成员关系(仅系统管理员) |
| `GET` | `/api/v1/platform/workspaces` | 列 workspace(`active`/`archived`)cursor 分页 + `q` 搜索,见 §7.0 | | `GET` | `/api/v1/platform/workspaces` | 列 workspace(`active`/`archived`)cursor 分页 + `q` 搜索,见 §7.0 |
| `POST` | `/api/v1/platform/workspaces` | 创建 workspace(返回 201);创建者自动成为 admin 成员 | | `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`。 - **响应 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}` ### 7.11 `DELETE /api/v1/platform/employees/{user_id}`
软删除平台员工;级联软删其所有 `workspace_members` 行。调用者必须是系统管理员。 软删除平台员工;级联软删其所有 `workspace_members` 行。调用者必须是系统管理员。
+110 -16
View File
@@ -12,6 +12,8 @@ The user-facing flow is:
``backend.api.dependencies.request_context`` ``backend.api.dependencies.request_context``
3. POST /api/v1/auth/logout — clear the cookie 3. POST /api/v1/auth/logout — clear the cookie
4. GET /api/v1/auth/me — return the current user 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 Service-to-service calls do not use these endpoints — they live on the
shared Docker network and have no application-layer auth. See 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.jwt import JwtError, issue_jwt, verify_jwt_token
from common.auth.membership import resolve_is_system_admin 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.config import settings
from common.db.models import Roles, Users, WorkspaceMembers, Workspaces from common.db.models import Roles, Users, WorkspaceMembers, Workspaces
from common.ids import new_ulid from common.ids import new_ulid
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession 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"]) 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,并返回用户可进入的工作区列表。 # 校验账号密码,设置登录 Cookie,并返回用户可进入的工作区列表。
@router.post("/login") @router.post("/login")
async def login( async def login(
@@ -167,13 +193,7 @@ async def login(
# (``Users.platform_role_id``); the iteration over rows above was # (``Users.platform_role_id``); the iteration over rows above was
# a legacy way to find the "highest" workspace role and is no # a legacy way to find the "highest" workspace role and is no
# longer correct now that admin/developer are platform-only. # longer correct now that admin/developer are platform-only.
user_role_code: str | None = None user_role_code = await _platform_role_code(session, user)
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
token = issue_jwt(user.user_id, ttl_seconds=COOKIE_TTL_SECONDS) token = issue_jwt(user.user_id, ttl_seconds=COOKIE_TTL_SECONDS)
_set_session_cookie(request, response, token) _set_session_cookie(request, response, token)
@@ -257,13 +277,7 @@ async def me(
workspaces = [_workspace_payload(ws, role) for ws, role, _ in rows] workspaces = [_workspace_payload(ws, role) for ws, role, _ in rows]
default_workspace_id = workspaces[0]["workspace_id"] if workspaces else None default_workspace_id = workspaces[0]["workspace_id"] if workspaces else None
user_role_code: str | None = None user_role_code = await _platform_role_code(session, user)
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
is_system_admin = await resolve_is_system_admin(session, user) is_system_admin = await resolve_is_system_admin(session, user)
permissions = await load_user_permissions(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__ = [ __all__ = [
"COOKIE_NAME", "COOKIE_NAME",
"COOKIE_TTL_SECONDS", "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"
+3
View File
@@ -6,6 +6,9 @@
/.react-router/ /.react-router/
/build/ /build/
# Vite / file-viewer 构建产物,不要提交
/dist/
# monaco-editor 预构建 min/vs(由 scripts/copy-monaco.mjs 在 postinstall 时 # monaco-editor 预构建 min/vs(由 scripts/copy-monaco.mjs 在 postinstall 时
# 从 node_modules/monaco-editor/min/vs 复制生成,不要提交) # 从 node_modules/monaco-editor/min/vs 复制生成,不要提交)
/public/monaco/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>
);
}
+74 -12
View File
@@ -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 type { AuthUser, AuthWorkspace } from "../../context/AuthContext";
import { AccountSettingsDialog } from "./AccountSettingsDialog";
type TopbarProps = { type TopbarProps = {
pageTitle: string; pageTitle: string;
@@ -24,6 +26,26 @@ export function Topbar({
onSetCurrentWorkspace, onSetCurrentWorkspace,
onLogout, onLogout,
}: TopbarProps) { }: 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 ( return (
<header className="relative z-[2] flex h-[70px] min-h-[70px] items-center justify-between border-b border-[#dfe6ee] bg-white px-[22px]"> <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]"> <div className="flex items-center gap-[9px]">
@@ -57,7 +79,10 @@ export function Topbar({
<button <button
className="flex h-[46px] cursor-pointer items-center gap-[9px] rounded-lg border border-line bg-white px-[11px] hover:border-[#b9cce0]" className="flex h-[46px] cursor-pointer items-center gap-[9px] rounded-lg border border-line bg-white px-[11px] hover:border-[#b9cce0]"
type="button" 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"> <span className="grid size-[30px] place-items-center rounded-[7px] bg-gradient-to-br from-[#23aa73] to-[#158d5d] text-white">
<LayoutGrid size={18} /> <LayoutGrid size={18} />
@@ -104,10 +129,13 @@ export function Topbar({
</div> </div>
)} )}
</div> </div>
<div className="relative flex items-center gap-[9px]"> <div className="relative" ref={userMenuRef}>
<button <button
className="flex h-[46px] cursor-pointer items-center gap-[9px] rounded-lg border border-transparent bg-white px-[11px] hover:border-[#b9cce0]" className="flex h-[46px] cursor-pointer items-center gap-[9px] rounded-lg border border-transparent bg-white px-[11px] hover:border-[#b9cce0]"
type="button" 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"> <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) ?? "?"} {user?.display_name?.slice(0, 1) ?? "?"}
@@ -120,18 +148,52 @@ export function Topbar({
{user?.role_code === "admin" ? "管理员" : "开发人员"} {user?.role_code === "admin" ? "管理员" : "开发人员"}
</small> </small>
</span> </span>
<ChevronRight size={15} className="text-[#97a3b1]" />
</button> </button>
<button {userMenuOpen && (
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]" <div
type="button" className="absolute top-[calc(100%+7px)] right-0 z-50 w-[240px] rounded-lg border border-line bg-white p-1.5 shadow-lg"
onClick={() => { role="menu"
void onLogout(); >
}} <div className="border-b border-line px-2.5 py-2.5">
> <strong className="block truncate text-[12px] text-[#243a50]">
{user?.display_name ?? "未知用户"}
</button> </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="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>
</div> </div>
<AccountSettingsDialog open={settingsOpen} onOpenChange={setSettingsOpen} />
</header> </header>
); );
} }
+45 -1
View File
@@ -48,6 +48,14 @@ type AuthContextValue = {
loading: boolean; loading: boolean;
login: (username: string, password: string) => Promise<void>; login: (username: string, password: string) => Promise<void>;
logout: () => 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; setCurrentWorkspace: (workspaceId: string) => void;
refreshWorkspaces: () => Promise<void>; refreshWorkspaces: () => Promise<void>;
}; };
@@ -162,6 +170,27 @@ export function AuthProvider({ children }: { children: ReactNode }) {
} }
}, [navigate]); }, [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 setCurrentWorkspace = useCallback((workspaceId: string) => {
const workspace = workspaces.find((item) => item.workspace_id === workspaceId); const workspace = workspaces.find((item) => item.workspace_id === workspaceId);
if (!workspace) return; if (!workspace) return;
@@ -193,9 +222,22 @@ export function AuthProvider({ children }: { children: ReactNode }) {
loading, loading,
login, login,
logout, logout,
updateProfile,
changePassword,
setCurrentWorkspace, setCurrentWorkspace,
refreshWorkspaces, refreshWorkspaces,
}), [currentWorkspace, loading, login, logout, setCurrentWorkspace, user, workspaces, refreshWorkspaces]); }), [
changePassword,
currentWorkspace,
loading,
login,
logout,
refreshWorkspaces,
setCurrentWorkspace,
updateProfile,
user,
workspaces,
]);
if (loading) { if (loading) {
return ( return (
@@ -283,6 +325,8 @@ export function useApi(): WorkspaceBoundApi {
rawApi.updatePlatformEmployee(userId, input), rawApi.updatePlatformEmployee(userId, input),
deleteEmployee: (userId) => rawApi.deleteEmployee(workspaceId, userId), deleteEmployee: (userId) => rawApi.deleteEmployee(workspaceId, userId),
deletePlatformEmployee: (userId) => rawApi.deletePlatformEmployee(userId), deletePlatformEmployee: (userId) => rawApi.deletePlatformEmployee(userId),
resetPlatformEmployeePassword: (userId, newPassword) =>
rawApi.resetPlatformEmployeePassword(userId, newPassword),
// Role Management - 角色管理接口(跨 workspace,不需要传入 workspaceId // Role Management - 角色管理接口(跨 workspace,不需要传入 workspaceId
listPlatformRoles: () => rawApi.listPlatformRoles(), listPlatformRoles: () => rawApi.listPlatformRoles(),
getPlatformRole: (roleCode) => rawApi.getPlatformRole(roleCode), 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 { CreateUserProjectField } from "./CreateUserProjectField";
import { import {
formFieldClass, formFieldClass,
formHintClass,
formInputClass, formInputClass,
modalFormClass, modalFormClass,
} from "../platform/modalUi"; } from "../platform/modalUi";
@@ -40,6 +41,7 @@ export function UserFormDialog({
onToggleWorkspace, onToggleWorkspace,
onSubmit, onSubmit,
onClose, onClose,
onResetPassword,
}: { }: {
open: boolean; open: boolean;
editing: Employee | null; editing: Employee | null;
@@ -55,6 +57,7 @@ export function UserFormDialog({
onToggleWorkspace: (workspaceId: string) => void; onToggleWorkspace: (workspaceId: string) => void;
onSubmit: (event: React.FormEvent) => void; onSubmit: (event: React.FormEvent) => void;
onClose: () => void; onClose: () => void;
onResetPassword?: () => void;
}) { }) {
return ( return (
<AppFormDialog <AppFormDialog
@@ -189,6 +192,23 @@ export function UserFormDialog({
</select> </select>
</label> </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"> <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 <Button
variant="outline" variant="outline"
@@ -16,6 +16,7 @@ import {
import { AdminColgroup, USER_PROJECT_COL_WIDTHS } from "./AdminTable"; import { AdminColgroup, USER_PROJECT_COL_WIDTHS } from "./AdminTable";
import { AdminPagination } from "./AdminPagination"; import { AdminPagination } from "./AdminPagination";
import { UserFormDialog, type UserFormState } from "./UserFormDialog"; import { UserFormDialog, type UserFormState } from "./UserFormDialog";
import { ResetPasswordDialog } from "./ResetPasswordDialog";
import { useCursorPage } from "./useCursorPage"; import { useCursorPage } from "./useCursorPage";
import { import {
adminEmptyClass, adminEmptyClass,
@@ -61,6 +62,8 @@ export function UserManagementPage({
const [form, setForm] = useState<UserFormState>(EMPTY_FORM); const [form, setForm] = useState<UserFormState>(EMPTY_FORM);
const [userSearchTerm, setUserSearchTerm] = useState(""); const [userSearchTerm, setUserSearchTerm] = useState("");
const [deleteTarget, setDeleteTarget] = useState<Employee | null>(null); 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 [createProjects, setCreateProjects] = useState<Workspace[]>([]);
const [createProjectsLoading, setCreateProjectsLoading] = useState(false); const [createProjectsLoading, setCreateProjectsLoading] = useState(false);
const [selectedWorkspaceIds, setSelectedWorkspaceIds] = useState<string[]>([]); 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(() => { const emptyMessage = useMemo(() => {
if (loading) return "正在加载用户…"; if (loading) return "正在加载用户…";
if (debouncedSearch.trim()) return "未找到匹配的用户"; if (debouncedSearch.trim()) return "未找到匹配的用户";
@@ -379,6 +399,14 @@ export function UserManagementPage({
onToggleWorkspace={toggleCreateWorkspace} onToggleWorkspace={toggleCreateWorkspace}
onSubmit={(event) => void submit(event)} onSubmit={(event) => void submit(event)}
onClose={() => setDialogOpen(false)} onClose={() => setDialogOpen(false)}
onResetPassword={
editing
? () => {
setResetTarget(editing);
setDialogOpen(false);
}
: undefined
}
/> />
<ConfirmDialog <ConfirmDialog
@@ -400,6 +428,15 @@ export function UserManagementPage({
setDeleteTarget(null); setDeleteTarget(null);
}} }}
/> />
<ResetPasswordDialog
open={resetTarget !== null}
displayName={resetTarget?.display_name ?? ""}
username={resetTarget?.username ?? ""}
saving={resetSaving}
onSubmit={executeResetPassword}
onClose={() => setResetTarget(null)}
/>
</section> </section>
); );
} }
+4
View File
@@ -267,6 +267,10 @@ export type WorkspaceBoundApi = {
deletePlatformEmployee: ( deletePlatformEmployee: (
userId: string, userId: string,
) => Promise<{ user_id: string; deleted: boolean }>; ) => Promise<{ user_id: string; deleted: boolean }>;
resetPlatformEmployeePassword: (
userId: string,
newPassword: string,
) => Promise<{ user_id: string; password_reset: boolean }>;
listPlatformRoles: () => Promise<Role[]>; listPlatformRoles: () => Promise<Role[]>;
getPlatformRole: (roleCode: string) => Promise<Role>; getPlatformRole: (roleCode: string) => Promise<Role>;
listPlatformPermissions: () => Promise<PlatformPermission[]>; 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 }) },
);
}