Merge remote-tracking branch 'origin/develop' into develop
This commit is contained in:
@@ -137,6 +137,11 @@ async def create_employee(
|
||||
email=payload.email.strip() if payload.email else None,
|
||||
password_hash=hash_password(payload.password),
|
||||
status="active",
|
||||
# 不变量: workspace admin == 系统管理员, 因此 role_code == "admin"
|
||||
# 时必须同步把 platform_role_id 指向 admin role, 否则
|
||||
# resolve_is_system_admin 会返回 False, 但前端的 role_code
|
||||
# 判断仍然命中, 两端出现分歧。
|
||||
platform_role_id=role.role_id if role.role_code == "admin" else None,
|
||||
)
|
||||
session.add(user)
|
||||
session.add(
|
||||
@@ -169,9 +174,32 @@ async def update_employee(
|
||||
user.display_name = payload.display_name.strip()
|
||||
if payload.email is not None:
|
||||
user.email = payload.email.strip() or None
|
||||
if payload.status is not None:
|
||||
if payload.status is not None and payload.status != user.status:
|
||||
if user.user_id == context.user.user_id and payload.status != "active":
|
||||
raise HTTPException(
|
||||
status.HTTP_409_CONFLICT,
|
||||
"不能停用当前登录账号",
|
||||
)
|
||||
if role.role_code == "admin" and payload.status in ("disabled", "locked"):
|
||||
raise HTTPException(
|
||||
status.HTTP_409_CONFLICT,
|
||||
"不能停用或锁定管理员账号",
|
||||
)
|
||||
user.status = payload.status
|
||||
if payload.role_code is not None and payload.role_code != role.role_code:
|
||||
if role.role_code == "admin" and payload.role_code != "admin":
|
||||
raise HTTPException(
|
||||
status.HTTP_409_CONFLICT,
|
||||
"不能降级管理员账号",
|
||||
)
|
||||
if (
|
||||
user.user_id == context.user.user_id
|
||||
and payload.role_code != "admin"
|
||||
):
|
||||
raise HTTPException(
|
||||
status.HTTP_409_CONFLICT,
|
||||
"不能降级自身管理员角色",
|
||||
)
|
||||
next_role = await session.scalar(
|
||||
select(Roles).where(Roles.role_code == payload.role_code)
|
||||
)
|
||||
|
||||
@@ -471,6 +471,12 @@ async def add_member(
|
||||
status.HTTP_409_CONFLICT,
|
||||
"用户已是该 workspace 成员",
|
||||
)
|
||||
# 不变量: 给用户授予 workspace admin 时同步设置 platform_role_id,
|
||||
# 否则前端 role_code === "admin" 与后端 is_system_admin 会给出
|
||||
# 不同的结论。降级路径不在此处处理(用户可能在其他 workspace
|
||||
# 仍是 admin), 升级路径必须在此处理。
|
||||
if role.role_code == "admin" and user.platform_role_id is None:
|
||||
user.platform_role_id = role.role_id
|
||||
membership = WorkspaceMembers(
|
||||
workspace_id=workspace_id,
|
||||
user_id=payload.user_id,
|
||||
@@ -529,6 +535,12 @@ async def update_member(
|
||||
)
|
||||
next_role = await _load_role_by_code(session, payload.role_code)
|
||||
membership.role_id = next_role.role_id
|
||||
# 不变量: 升级到 workspace admin 时强制覆盖 platform_role_id。
|
||||
# (用户可能之前是 developer, platform_role_id 指向 developer role,
|
||||
# 现在变成 admin 必须提升到 admin role。) 降级路径不动 (用户
|
||||
# 可能在其他 workspace 仍是 admin, 端点看不到全局)。
|
||||
if next_role.role_code == "admin":
|
||||
user.platform_role_id = next_role.role_id
|
||||
|
||||
if payload.member_status is not None and payload.member_status != membership.member_status:
|
||||
if (
|
||||
|
||||
@@ -79,6 +79,27 @@ export function UserManagementPage({
|
||||
onNotify({ tone: "error", message: "请输入密码" });
|
||||
return;
|
||||
}
|
||||
// 编辑自身或管理员时,前端二次拦截禁用 status / role_code 的修改
|
||||
if (editing) {
|
||||
const editingSelf = editing.user_id === user?.user_id;
|
||||
const targetIsAdmin = editing.role_code === "admin";
|
||||
if (editingSelf && form.status !== editing.status) {
|
||||
onNotify({ tone: "error", message: "不能停用当前登录账号" });
|
||||
return;
|
||||
}
|
||||
if (targetIsAdmin && form.status !== editing.status) {
|
||||
onNotify({ tone: "error", message: "不能停用或锁定管理员账号" });
|
||||
return;
|
||||
}
|
||||
if (targetIsAdmin && form.role_code !== editing.role_code) {
|
||||
onNotify({ tone: "error", message: "不能降级管理员账号" });
|
||||
return;
|
||||
}
|
||||
if (editingSelf && form.role_code !== editing.role_code) {
|
||||
onNotify({ tone: "error", message: "不能降级自身管理员角色" });
|
||||
return;
|
||||
}
|
||||
}
|
||||
// 密码长度校验 8~72 字符
|
||||
if (form.password && (form.password.length < 8 || form.password.length > 72)) {
|
||||
onNotify({ tone: "error", message: "密码长度必须在 8~72 字符之间" });
|
||||
@@ -245,7 +266,7 @@ export function UserManagementPage({
|
||||
</label>
|
||||
<label className="form-field">
|
||||
<span>角色</span>
|
||||
<select value={form.role_code} onChange={(event) => setForm({ ...form, role_code: event.target.value as "admin" | "developer" })}>
|
||||
<select value={form.role_code} disabled={Boolean(editing) && ((editing as Employee).user_id === user?.user_id || (editing as Employee).role_code === "admin")} onChange={(event) => setForm({ ...form, role_code: event.target.value as "admin" | "developer" })}>
|
||||
<option value="developer">开发人员</option>
|
||||
<option value="admin">管理员</option>
|
||||
</select>
|
||||
@@ -253,7 +274,7 @@ export function UserManagementPage({
|
||||
{editing && (
|
||||
<label className="form-field">
|
||||
<span>状态</span>
|
||||
<select value={form.status} onChange={(event) => setForm({ ...form, status: event.target.value as typeof form.status })}>
|
||||
<select value={form.status} disabled={Boolean(editing) && ((editing as Employee).user_id === user?.user_id || (editing as Employee).role_code === "admin")} onChange={(event) => setForm({ ...form, status: event.target.value as typeof form.status })}>
|
||||
<option value="active">正常</option>
|
||||
<option value="disabled">停用</option>
|
||||
<option value="locked">锁定</option>
|
||||
|
||||
@@ -19,6 +19,7 @@ export type AuthUser = {
|
||||
email: string | null;
|
||||
status: string;
|
||||
role_code: string | null;
|
||||
is_system_admin: boolean;
|
||||
};
|
||||
|
||||
export type AuthWorkspace = {
|
||||
|
||||
@@ -12,5 +12,13 @@ RUN mkdir -p /root/.jupyter/custom /app/.venv/share/jupyter/custom && \
|
||||
echo '#top-panel, #top-panel-wrapper, .jp-Notebook-header, .jp-FileEditorHeader {display: none !important; height: 0 !important; min-height: 0 !important; margin: 0 !important; padding: 0 !important; border: none !important; position: absolute !important; pointer-events: none !important; }' > /root/.jupyter/custom/custom.css && \
|
||||
cp /root/.jupyter/custom/custom.css /app/.venv/share/jupyter/custom/custom.css
|
||||
|
||||
RUN uv venv --python 3.8 /opt/venv/python3.8 && \
|
||||
UV_DEFAULT_INDEX="https://pypi.tuna.tsinghua.edu.cn/simple/" uv pip install --python /opt/venv/python3.8/bin/python ipykernel && \
|
||||
/opt/venv/python3.8/bin/python -m ipykernel install --prefix=/app/.venv --name=python38 --display-name="python3.8" \
|
||||
|
||||
RUN uv venv --python 3.10 /opt/venv/python3.10 && \
|
||||
UV_DEFAULT_INDEX="https://pypi.tuna.tsinghua.edu.cn/simple/" uv pip install --python /opt/venv/python3.10/bin/python ipykernel && \
|
||||
/opt/venv/python3.10/bin/python -m ipykernel install --prefix=/app/.venv --name=python310 --display-name="python3.10"
|
||||
|
||||
EXPOSE 8000
|
||||
CMD ["uv", "run", "--frozen", "--package", "runtime", "gunicorn", "--config", "runtime/gunicorn.conf.py", "runtime.main:app"]
|
||||
|
||||
@@ -17,6 +17,9 @@ WORKDIR /app
|
||||
# uv 包管理器(多个业务镜像都需要)
|
||||
COPY --from=ghcr.io/astral-sh/uv:latest /uv /bin/uv
|
||||
|
||||
RUN uv python install 3.8
|
||||
RUN uv python install 3.10
|
||||
|
||||
# 替换 apt 源为阿里云镜像(兼容 debian.sources / sources.list 两种格式)
|
||||
RUN ( \
|
||||
sed -i 's/deb.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list.d/debian.sources 2>/dev/null || \
|
||||
|
||||
Reference in New Issue
Block a user