fix(permission): dept admin role list, org access, user groups

- Department admin: FGA admin check with DB parent-chain fallback

- User groups: public groups manageable by dept admins; member-edit empty hint

- Roles: list_roles uses same admin-dept query as org UI; subtree filter + global read-only

- Role scope full path; hide edit/delete for readonly rows

- Alembic v2_5_0_f010 user name non-unique; client/platform URL and i18n updates

Made-with: Cursor
This commit is contained in:
30388
2026-04-17 21:59:45 +08:00
parent d574df9f76
commit 94323e3ec8
48 changed files with 1235 additions and 366 deletions
@@ -88,3 +88,8 @@ class DepartmentNotArchivedError(BaseErrorCode):
class DepartmentArchivedReadonlyError(BaseErrorCode):
Code: int = 21017
Msg: str = 'Archived departments cannot be modified'
class DepartmentParentArchivedError(BaseErrorCode):
Code: int = 21018
Msg: str = 'Cannot restore department while parent department is archived'
+97 -16
View File
@@ -76,6 +76,8 @@ async def init_default_data():
await session.refresh(user)
await UserRoleDao.set_admin_user(user.user_id)
await _backfill_guest_department_membership(session)
# Initialize preset application templates
templates = await session.exec(select(Template).limit(1))
templates = templates.all()
@@ -216,32 +218,111 @@ async def _init_default_root_department(session):
from bisheng.database.models.department import Department
from bisheng.database.models.tenant import Tenant
guest_dept_id = 'BS@guest'
guest_name = '临时访客'
guest_sort_order = 2147483647
with bypass_tenant_filter():
# Check if default tenant exists and has no root_dept_id yet
tenant = (await session.exec(
select(Tenant).where(Tenant.id == DEFAULT_TENANT_ID)
)).first()
if tenant is None or tenant.root_dept_id is not None:
if tenant is None:
return
dept = Department(
dept_id='BS@root',
name='Default Organization',
parent_id=None,
tenant_id=DEFAULT_TENANT_ID,
path='',
source='local',
status='active',
root_dept = None
if tenant.root_dept_id is not None:
root_dept = (await session.exec(
select(Department).where(Department.id == tenant.root_dept_id)
)).first()
if root_dept is None:
root_dept = Department(
dept_id='BS@root',
name='Default Organization',
parent_id=None,
tenant_id=DEFAULT_TENANT_ID,
path='',
source='local',
status='active',
)
session.add(root_dept)
await session.flush()
await session.refresh(root_dept)
root_dept.path = f'/{root_dept.id}/'
tenant.root_dept_id = root_dept.id
session.add(root_dept)
await session.commit()
guest = (await session.exec(
select(Department).where(Department.dept_id == guest_dept_id)
)).first()
if guest is None:
guest = Department(
dept_id=guest_dept_id,
name=guest_name,
parent_id=root_dept.id,
tenant_id=DEFAULT_TENANT_ID,
path=f'{root_dept.path}',
source='local',
status='active',
sort_order=guest_sort_order,
)
session.add(guest)
await session.flush()
await session.refresh(guest)
guest.path = f'{root_dept.path}{guest.id}/'
session.add(guest)
await session.commit()
else:
changed = False
if guest.parent_id != root_dept.id:
guest.parent_id = root_dept.id
changed = True
if guest.sort_order != guest_sort_order:
guest.sort_order = guest_sort_order
changed = True
expected_path_prefix = root_dept.path or ''
if not (guest.path or '').startswith(expected_path_prefix):
guest.path = f'{expected_path_prefix}{guest.id}/'
changed = True
if changed:
session.add(guest)
await session.commit()
logger.info(f'Default root department ready (id={root_dept.id}), guest dept ready (dept_id={guest_dept_id})')
async def _backfill_guest_department_membership(session):
"""Ensure users have at least one department; fallback to 临时访客."""
from bisheng.database.models.department import Department, UserDepartment
guest = (await session.exec(
select(Department).where(
Department.dept_id == 'BS@guest',
Department.status == 'active',
)
session.add(dept)
await session.flush()
await session.refresh(dept)
)).first()
if not guest:
return
dept.path = f'/{dept.id}/'
tenant.root_dept_id = dept.id
await session.commit()
user_rows = (await session.exec(select(User.user_id))).all()
if not user_rows:
return
logger.info(f'Default root department initialized (id={dept.id})')
for uid in user_rows:
has_any = (await session.exec(
select(UserDepartment.id).where(UserDepartment.user_id == uid)
)).first()
if has_any is not None:
continue
session.add(UserDepartment(
user_id=uid,
department_id=guest.id,
is_primary=1,
source='local',
))
await session.commit()
async def _migrate_rbac_to_rebac_if_needed():
@@ -0,0 +1,84 @@
"""Allow duplicate user_name; person_id (external_id) remains unique per source.
Revision ID: f010_user_name_nonunique
Revises: f009_org_sync
Create Date: 2026-04-17
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = 'f010_user_name_nonunique'
down_revision: Union[str, Sequence[str], None] = 'f009_org_sync'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def _table_exists(table_name: str) -> bool:
conn = op.get_bind()
result = conn.execute(
sa.text(
'SELECT COUNT(*) FROM information_schema.TABLES '
'WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = :t',
),
{'t': table_name},
)
return result.scalar() > 0
def upgrade() -> None:
"""Drop UNIQUE on user.user_name; keep a non-unique index for lookup."""
if not _table_exists('user'):
return
conn = op.get_bind()
rows = conn.execute(
sa.text(
"""
SELECT DISTINCT INDEX_NAME, NON_UNIQUE
FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'user'
AND COLUMN_NAME = 'user_name'
AND INDEX_NAME != 'PRIMARY'
""",
),
).fetchall()
for index_name, non_unique in rows:
if int(non_unique) == 0:
op.drop_index(index_name, table_name='user')
# Recreate plain index if none remains on user_name
has_any = conn.execute(
sa.text(
"""
SELECT COUNT(*) FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'user'
AND COLUMN_NAME = 'user_name'
AND INDEX_NAME != 'PRIMARY'
""",
),
).scalar()
if not has_any:
op.create_index('ix_user_user_name', 'user', ['user_name'], unique=False)
def downgrade() -> None:
"""Cannot safely restore UNIQUE if duplicates exist; only ensure index exists."""
if not _table_exists('user'):
return
conn = op.get_bind()
has_any = conn.execute(
sa.text(
"""
SELECT COUNT(*) FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'user'
AND COLUMN_NAME = 'user_name'
AND INDEX_NAME != 'PRIMARY'
""",
),
).scalar()
if not has_any:
op.create_index('ix_user_user_name', 'user', ['user_name'], unique=False)
+48 -19
View File
@@ -1,7 +1,7 @@
from datetime import datetime
from typing import List, Optional
from sqlalchemy import Column, DateTime, Integer, JSON, String, text, func, delete, and_, or_, UniqueConstraint
from sqlalchemy import Column, DateTime, Integer, JSON, String, false, text, func, delete, and_, or_, UniqueConstraint
from sqlmodel import Field, select
from bisheng.common.models.base import SQLModelSerializable
@@ -172,15 +172,22 @@ class RoleDao(RoleBase):
# ── New methods for F005 (AD-09) ──
@classmethod
def _build_visible_roles_stmt(cls, tenant_id: int, keyword: str = None,
department_ids: List[int] = None):
def _build_visible_roles_stmt(
cls,
tenant_id: int,
keyword: str = None,
department_ids: List[int] = None,
tenant_custom_roles_only: bool = False,
):
"""Build statement for visible roles: global + current tenant, excluding AdminRole.
Args:
tenant_id: Current tenant ID.
keyword: Optional keyword filter on role_name.
department_ids: Optional list of department IDs to filter by
(for department admin scope). None = no dept filtering.
department_ids: When set (department admin): **global** preset roles (read-only in
UI) plus **tenant** roles whose ``department_id`` is in this subtree.
tenant_custom_roles_only: When True and ``department_ids`` is None (tenant admin),
exclude ``global`` roles so the list only contains editable tenant roles.
"""
# bypass_tenant_filter is used by callers, so explicitly scope tenant roles
stmt = select(Role).where(
@@ -193,27 +200,42 @@ class RoleDao(RoleBase):
if keyword:
stmt = stmt.where(Role.role_name.like(f'%{keyword}%'))
if department_ids is not None:
# Department admin: only see global roles + roles in their dept subtree
stmt = stmt.where(
or_(
Role.role_type == 'global',
Role.department_id.in_(department_ids),
Role.department_id.is_(None),
if not department_ids:
stmt = stmt.where(Role.role_type == 'global')
else:
stmt = stmt.where(
or_(
Role.role_type == 'global',
and_(
Role.role_type == 'tenant',
Role.department_id.isnot(None),
Role.department_id.in_(department_ids),
),
),
)
)
elif tenant_custom_roles_only:
stmt = stmt.where(Role.role_type == 'tenant')
return stmt
@classmethod
async def aget_visible_roles(cls, tenant_id: int, keyword: str = None,
page: int = 1, limit: int = 10,
department_ids: List[int] = None) -> List[Role]:
async def aget_visible_roles(
cls,
tenant_id: int,
keyword: str = None,
page: int = 1,
limit: int = 10,
department_ids: List[int] = None,
tenant_custom_roles_only: bool = False,
) -> List[Role]:
"""Get visible roles for role list API (AD-09).
Returns global roles + current tenant's roles, excluding AdminRole(id=1).
Uses bypass_tenant_filter so global roles from other tenants are visible.
"""
from bisheng.core.context.tenant import bypass_tenant_filter
stmt = cls._build_visible_roles_stmt(tenant_id, keyword, department_ids)
stmt = cls._build_visible_roles_stmt(
tenant_id, keyword, department_ids, tenant_custom_roles_only,
)
stmt = stmt.order_by(Role.create_time.desc())
if page and limit:
stmt = stmt.offset((page - 1) * limit).limit(limit)
@@ -222,11 +244,18 @@ class RoleDao(RoleBase):
return (await session.exec(stmt)).all()
@classmethod
async def acount_visible_roles(cls, tenant_id: int, keyword: str = None,
department_ids: List[int] = None) -> int:
async def acount_visible_roles(
cls,
tenant_id: int,
keyword: str = None,
department_ids: List[int] = None,
tenant_custom_roles_only: bool = False,
) -> int:
"""Count visible roles (companion for aget_visible_roles)."""
from bisheng.core.context.tenant import bypass_tenant_filter
base_stmt = cls._build_visible_roles_stmt(tenant_id, keyword, department_ids)
base_stmt = cls._build_visible_roles_stmt(
tenant_id, keyword, department_ids, tenant_custom_roles_only,
)
stmt = select(func.count()).select_from(base_stmt.subquery())
with bypass_tenant_filter():
async with get_async_db_session() as session:
@@ -53,6 +53,7 @@ async def create_local_member_body(
try:
inner = DepartmentLocalMemberCreate(
user_name=data.user_name,
person_id=data.person_id,
password=data.password,
role_ids=data.role_ids,
)
@@ -127,6 +128,18 @@ async def purge_department(
return e.return_resp_instance()
@router.post('/{dept_id}/restore')
async def restore_department(
dept_id: str,
login_user: UserPayload = Depends(UserPayload.get_login_user),
):
try:
await DepartmentService.arestore_department(dept_id, login_user)
return resp_200()
except BaseErrorCode as e:
return e.return_resp_instance()
@router.post('/{dept_id}/move')
async def move_department(
dept_id: str,
@@ -65,8 +65,12 @@ class DepartmentLocalMemberCreate(BaseModel):
"""PRD §3.2.1:在部门内创建本地人员(主部门固定为当前部门)。"""
user_name: str = Field(..., min_length=1, max_length=128)
person_id: str = Field(..., min_length=1, max_length=128, description='人员ID(登录唯一凭证)')
password: str = Field(..., description='RSA 加密后的密码(与 /user/create 一致)')
role_ids: List[int] = Field(..., min_length=1, description='全局角色 + 本部门角色的 id 列表')
role_ids: List[int] = Field(
default_factory=list,
description='可选;与部门「默认角色」合并后写入,均须落在当前部门可分配角色内',
)
class DepartmentLocalMemberCreateWithDeptId(DepartmentLocalMemberCreate):
@@ -8,7 +8,7 @@ from __future__ import annotations
import logging
import re
import secrets
from typing import List, Optional
from typing import List, Optional, Set
from sqlalchemy import and_, delete, func, or_, update
from sqlmodel import col, select
@@ -26,6 +26,7 @@ from bisheng.common.errcode.department import (
DepartmentNameDuplicateError,
DepartmentNotArchivedError,
DepartmentArchivedReadonlyError,
DepartmentParentArchivedError,
DepartmentNotFoundError,
DepartmentOpenFGAUnavailableError,
DepartmentPermissionDeniedError,
@@ -78,6 +79,13 @@ async def _check_permission(
dept_internal_id: Database ID of the target department. When provided,
checks if the user is an admin of that department (or any ancestor
via OpenFGA's parent-admin inheritance).
When OpenFGA is missing ``department:parent#parent department:child`` tuples,
inherited ``admin`` checks on the child can fail while MySQL ``path`` still
places the node under an admin's subtree (left tree vs member API mismatch).
In that case we walk ``parent_id`` in DB and re-use ``PermissionService.check``
on each ancestor — same outcome as a fully synced FGA graph without widening
scope beyond the org tree.
"""
# L1: System admin → pass
if _is_admin(login_user):
@@ -97,6 +105,23 @@ async def _check_permission(
)
if is_dept_admin:
return
# L2b: DB parent chain — tolerate missing FGA ``parent`` edges on some nodes
seen: Set[int] = {int(dept_internal_id)}
row = await DepartmentDao.aget_by_id(int(dept_internal_id))
while row is not None and row.parent_id is not None:
pid = int(row.parent_id)
if pid in seen:
break
seen.add(pid)
if await PermissionService.check(
user_id=login_user.user_id,
relation='admin',
object_type='department',
object_id=str(pid),
login_user=login_user,
):
return
row = await DepartmentDao.aget_by_id(pid)
except Exception:
logger.warning(
'PermissionService.check failed for dept admin, user=%d dept=%d',
@@ -111,6 +136,20 @@ def _get_dept_id_prefix() -> str:
return prefix if isinstance(prefix, str) and prefix else 'BS'
def _is_registration_enabled() -> bool:
"""Whether self-registration is enabled in system config."""
from bisheng.common.services.config_service import settings
env_conf = settings.get_from_db('env') or {}
if not isinstance(env_conf, dict):
return True
raw = env_conf.get('enable_registration', True)
if isinstance(raw, str):
return raw.strip().lower() in ('1', 'true', 'yes', 'on')
return bool(raw)
def generate_dept_id(prefix: str = 'BS') -> str:
"""Generate a business key like 'BS@a3f7e9'."""
return f'{prefix}@{secrets.token_hex(3)}'
@@ -253,6 +292,12 @@ class DepartmentService:
if not depts:
return []
# "临时访客" 仅在开放注册时展示
if not _is_registration_enabled():
depts = [d for d in depts if d.dept_id != 'BS@guest']
if not depts:
return []
# Filter to dept admin's subtree if not system admin
if not is_sys_admin:
admin_paths = {d.path for d in admin_depts}
@@ -370,6 +415,8 @@ class DepartmentService:
async def adelete_department(cls, dept_id: str, login_user) -> None:
async with get_async_db_session() as session:
dept = await _get_dept_and_check_permission(session, dept_id, login_user)
if dept.dept_id == 'BS@guest':
raise DepartmentPermissionDeniedError(msg='Guest department cannot be deleted')
# Check for children
children = (await session.exec(
@@ -416,6 +463,8 @@ class DepartmentService:
"""Permanently delete an archived department and clean up all references."""
async with get_async_db_session() as session:
dept = await _get_dept_and_check_permission(session, dept_id, login_user)
if dept.dept_id == 'BS@guest':
raise DepartmentPermissionDeniedError(msg='Guest department cannot be deleted')
if dept.status != 'archived':
raise DepartmentNotArchivedError()
@@ -489,6 +538,23 @@ class DepartmentService:
ops = DepartmentChangeHandler.on_purged(dept_internal_id, member_user_ids, admin_user_ids)
await DepartmentChangeHandler.execute_async(ops)
@classmethod
async def arestore_department(cls, dept_id: str, login_user) -> None:
"""Restore an archived department to active status."""
async with get_async_db_session() as session:
dept = await _get_dept_and_check_permission(session, dept_id, login_user)
if dept.status != 'archived':
raise DepartmentNotArchivedError(msg='Only archived departments can be restored')
if dept.parent_id is not None:
parent = await DepartmentDao.aget_by_id(dept.parent_id)
if parent and parent.status == 'archived':
raise DepartmentParentArchivedError()
dept.status = 'active'
session.add(dept)
await session.commit()
@classmethod
async def amove_department(
cls, dept_id: str, data: DepartmentMoveRequest, login_user,
@@ -597,8 +663,11 @@ class DepartmentService:
async def aadd_members(
cls, dept_id: str, data: DepartmentMemberAdd, login_user,
) -> None:
default_role_ids_snapshot: List[int] = []
async with get_async_db_session() as session:
dept = await _get_dept_and_check_permission(session, dept_id, login_user)
raw_defaults = dept.default_role_ids or []
default_role_ids_snapshot = [int(x) for x in raw_defaults if x is not None]
# Batch check for existing members (single query instead of N)
existing_result = await session.exec(
@@ -627,6 +696,24 @@ class DepartmentService:
ops = DepartmentChangeHandler.on_members_added(dept.id, data.user_ids)
await DepartmentChangeHandler.execute_async(ops)
# 部门「默认角色」:加入本部门后自动授予(可分配域内、且用户尚未拥有)
if default_role_ids_snapshot:
from bisheng.database.constants import AdminRole
from bisheng.user.domain.models.user_role import UserRoleDao
assignable = await cls.aget_assignable_roles(dept_id, login_user)
allowed_ids = {r['id'] for r in assignable}
to_apply = [
rid for rid in default_role_ids_snapshot
if rid != AdminRole and rid in allowed_ids
]
if to_apply:
for uid in data.user_ids:
existing = {int(ur.role_id) for ur in UserRoleDao.get_user_roles(uid)}
need_add = [r for r in to_apply if r not in existing]
if need_add:
UserRoleDao.add_user_roles(uid, need_add)
@classmethod
async def aremove_member(
cls, dept_id: str, user_id: int, login_user,
@@ -654,6 +741,34 @@ class DepartmentService:
)
await DepartmentChangeHandler.execute_async(ops)
@classmethod
async def _aget_department_admin_user_ids(cls, dept_internal_id: int) -> Set[int]:
"""OpenFGA:在 department:{id} 上具有 admin 关系的用户 ID 集合。"""
from bisheng.core.openfga.manager import aget_fga_client
fga = await aget_fga_client()
if fga is None:
return set()
try:
tuples = await fga.read_tuples(
relation='admin', object=f'department:{dept_internal_id}',
)
except Exception:
logger.warning(
'FGA read_tuples failed for department admin ids dept=%s', dept_internal_id,
)
return set()
user_ids: Set[int] = set()
for t in tuples:
user_str = t.get('user', '') if isinstance(t, dict) else ''
if not user_str and isinstance(t.get('key'), dict):
user_str = t['key'].get('user', '')
if user_str.startswith('user:'):
try:
user_ids.add(int(user_str.split(':', 1)[1]))
except ValueError:
continue
return user_ids
@classmethod
async def aget_admins(
cls, dept_id: str, login_user,
@@ -662,30 +777,7 @@ class DepartmentService:
async with get_async_db_session() as session:
dept = await _get_dept_and_check_permission(session, dept_id, login_user)
from bisheng.core.openfga.manager import aget_fga_client
fga = await aget_fga_client()
if fga is None:
return []
try:
tuples = await fga.read_tuples(
relation='admin', object=f'department:{dept.id}',
)
except Exception:
logger.warning('FGA read_tuples failed for department %s admins', dept_id)
return []
user_ids = []
for t in tuples:
# FGAClient.read_tuples returns OpenFGA tuple_key dicts: {user, relation, object}
user_str = t.get('user', '') if isinstance(t, dict) else ''
if not user_str and isinstance(t.get('key'), dict):
user_str = t['key'].get('user', '')
if user_str.startswith('user:'):
try:
user_ids.append(int(user_str.split(':', 1)[1]))
except ValueError:
continue
user_ids = list(await cls._aget_department_admin_user_ids(dept.id))
if not user_ids:
return []
@@ -751,6 +843,7 @@ class DepartmentService:
select(
UserDepartment.user_id,
User.user_name,
User.external_id.label('user_external_id'),
UserDepartment.department_id,
UserDepartment.is_primary,
UserDepartment.source,
@@ -824,6 +917,8 @@ class DepartmentService:
{'id': rid, 'role_name': rname},
)
admin_uids = await cls._aget_department_admin_user_ids(dept.id)
def _last_modified(row) -> object:
"""成员行「修改时间」:取加入部门、用户创建、用户最近更新中的最晚时刻。"""
times = [
@@ -834,10 +929,17 @@ class DepartmentService:
valid = [x for x in times if x is not None]
return max(valid) if valid else None
def _person_id_for_row(row) -> Optional[str]:
ext = getattr(row, 'user_external_id', None)
if ext:
return str(ext)
return None
data = [
{
'user_id': r.user_id,
'user_name': r.user_name,
'person_id': _person_id_for_row(r),
'department_id': r.department_id,
'is_primary': r.is_primary,
'source': r.source,
@@ -846,6 +948,7 @@ class DepartmentService:
'enabled': r.user_deleted == 0,
'user_groups': user_groups_map.get(r.user_id, []),
'roles': roles_map.get(r.user_id, []),
'is_department_admin': r.user_id in admin_uids,
}
for r in rows
]
@@ -873,26 +976,26 @@ class DepartmentService:
for row in raw_ids:
subtree_ids.append(int(row[0]) if isinstance(row, tuple) else int(row))
# 作用域:department_id 为空表示全租户/全局可选;否则仅当前上下文部门及其子树内可授予。
# role_type=='global' 的自定义角色仍可能带 department_id,不能再用「凡 global 即全出现」。
dept_scope = or_(
Role.department_id.is_(None),
Role.department_id.in_(subtree_ids),
)
stmt = select(Role).where(
Role.id > AdminRole,
or_(
Role.role_type == 'global',
and_(Role.role_type == 'global', dept_scope),
and_(
Role.role_type == 'tenant',
Role.tenant_id == login_user.tenant_id,
or_(
Role.department_id.is_(None),
Role.department_id.in_(subtree_ids),
),
dept_scope,
),
# 兼容旧数据:历史角色可能未写 role_type,默认按 tenant 角色处理
and_(
or_(Role.role_type.is_(None), Role.role_type == ''),
Role.tenant_id == login_user.tenant_id,
or_(
Role.department_id.is_(None),
Role.department_id.in_(subtree_ids),
),
dept_scope,
),
),
).order_by(Role.role_name.asc())
@@ -913,36 +1016,37 @@ class DepartmentService:
cls, dept_id: str, data: DepartmentLocalMemberCreate, login_user,
) -> dict:
"""在部门内创建本地账号:主属当前部门、指定角色、生成人员 ID(external_id);用户组非必填。"""
from bisheng.common.errcode.user import UserNameAlreadyExistError
from bisheng.database.constants import AdminRole
from bisheng.user.domain.models.user import User, UserDao
from bisheng.user.domain.services.user import UserService
from bisheng.utils import md5_hash
default_role_ids_snapshot: List[int] = []
async with get_async_db_session() as session:
dept = await _get_dept_and_check_permission(session, dept_id, login_user)
raw_defaults = dept.default_role_ids or []
default_role_ids_snapshot = [int(x) for x in raw_defaults if x is not None]
plain = UserService.decrypt_password_plain(data.password)
if not _password_meets_prd_policy(plain):
raise DepartmentInvalidPasswordError()
if await UserDao.aget_user_by_username(data.user_name):
raise UserNameAlreadyExistError()
assignable = await cls.aget_assignable_roles(dept_id, login_user)
allowed_ids = {r['id'] for r in assignable}
if AdminRole in data.role_ids or not set(data.role_ids).issubset(allowed_ids):
explicit_ids = [int(x) for x in (data.role_ids or [])]
default_filtered = [
rid for rid in default_role_ids_snapshot
if rid != AdminRole and rid in allowed_ids
]
final_role_ids = list(dict.fromkeys(explicit_ids + default_filtered))
if AdminRole in final_role_ids or not set(final_role_ids).issubset(allowed_ids):
raise DepartmentInvalidRolesError()
person_id = None
for _ in range(5):
candidate = generate_dept_id(_get_dept_id_prefix())
existing = await UserDao.aget_by_source_external_id('local', candidate)
if not existing:
person_id = candidate
break
person_id = (data.person_id or '').strip()
if not person_id:
person_id = f'BS@{secrets.token_hex(6)}'
raise DepartmentInvalidRolesError(msg='Person ID is required')
if await UserDao.aget_by_external_id(person_id):
raise DepartmentInvalidRolesError(msg='Person ID already exists')
pwd_hash = md5_hash(plain)
user = User(
@@ -952,7 +1056,7 @@ class DepartmentService:
external_id=person_id,
)
user = UserDao.add_user_with_groups_and_roles(
user, [], list(data.role_ids),
user, [], final_role_ids,
)
async with get_async_db_session() as session:
@@ -1312,7 +1416,6 @@ class DepartmentService:
from bisheng.database.constants import AdminRole
from bisheng.user.domain.models.user import UserDao
from bisheng.user.domain.models.user_role import UserRoleDao
from bisheng.common.errcode.user import UserNameAlreadyExistError
async with get_async_db_session() as session:
ctx_dept = await _get_dept_and_check_permission(session, dept_id, login_user)
@@ -1348,9 +1451,6 @@ class DepartmentService:
if data.user_name is not None and edit_mode == 'local_primary':
name = (data.user_name or '').strip()
if name and name != user.user_name:
exists = await UserDao.aget_user_by_username(name)
if exists and exists.user_id != user_id:
raise UserNameAlreadyExistError()
user.user_name = name
await UserDao.aupdate_user(user)
@@ -33,6 +33,7 @@ from bisheng.permission.domain.schemas.permission_schema import AuthorizeGrantIt
from bisheng.permission.domain.schemas.tuple_operation import TupleOperation
from bisheng.database.models.group_resource import ResourceTypeEnum
from bisheng.database.models.role import RoleDao
from bisheng.role.domain.services.quota_service import QuotaService
from bisheng.database.models.tag import TagDao, TagBusinessTypeEnum, Tag
from bisheng.knowledge.domain.knowledge_rag import KnowledgeRag
from bisheng.knowledge.domain.models.knowledge import Knowledge, KnowledgeDao, KnowledgeTypeEnum, AuthTypeEnum, \
@@ -1559,13 +1560,19 @@ class KnowledgeSpaceService(KnowledgeUtils):
parent_type = 'folder'
parent_resource_id = parent_id
# GB → bytes cap for space owner; multi-role 取各角色上限的最大值;-1 表示不限制
default_file_size_limit = 40
roles = await UserRoleDao.aget_user_roles(db_knowledge.user_id)
if roles:
roles = await RoleDao.aget_role_by_ids([one.role_id for one in roles])
for one in roles:
default_file_size_limit = max(default_file_size_limit, one.knowledge_space_file_limit)
role_rows = await RoleDao.aget_role_by_ids([one.role_id for one in roles])
for one in role_rows:
gb = QuotaService.role_knowledge_space_file_limit_gb(one)
if gb == -1:
default_file_size_limit = 10**9
break
if gb > 0:
default_file_size_limit = max(default_file_size_limit, gb)
default_file_size_limit = default_file_size_limit * 1024 * 1024 * 1024
current_total_file_size = int(await SpaceFileDao.get_user_total_file_size(self.login_user.user_id))
@@ -33,6 +33,10 @@ class RoleListResponse(BaseModel):
role_type: str # 'global' | 'tenant'
department_id: Optional[int] = None
department_name: Optional[str] = None
department_scope_path: Optional[str] = Field(
default=None,
description='从组织根到作用域部门的完整路径(基于 Department.path),与前端树是否裁剪无关',
)
quota_config: Optional[dict] = None
remark: Optional[str] = None
user_count: int = 0
@@ -194,9 +194,26 @@ class QuotaService:
))
return items
@classmethod
def role_knowledge_space_file_limit_gb(cls, role) -> int:
"""Single role: storage quota (GB) from quota_config vs deprecated column.
Returns ``-1`` when quota_config marks unlimited for this role.
"""
qc = (role.quota_config or {}).get('knowledge_space_file')
if qc == -1:
return -1
legacy = getattr(role, 'knowledge_space_file_limit', None) or 0
candidates: list[int] = []
if qc is not None and isinstance(qc, int) and qc > 0:
candidates.append(qc)
if isinstance(legacy, int) and legacy > 0:
candidates.append(legacy)
return max(candidates) if candidates else 0
@classmethod
def _compute_role_quotas(cls, roles) -> dict:
"""Compute multi-role quota: take max per resource type, -1 wins."""
"""Compute multi-role quota: take max per resource type, -1 wins (AC-16)."""
result = {}
for resource_type in DEFAULT_ROLE_QUOTA:
max_q = None
@@ -2,8 +2,9 @@
Four-level permission check for role management:
1. System admin → full access
2. Tenant admin → tenant roles read/write, global roles read-only
3. Department admin → dept subtree roles only, global read-only
2. Tenant admin → tenant roles read/write; role list hides global (non-manageable) rows
3. Department admin → list shows global presets (read-only) + tenant roles in managed subtree;
update/delete only where ``_is_readonly`` is false
4. Regular user → denied (24003)
"""
@@ -153,12 +154,70 @@ class RoleService:
department_ids = None
permission_level = 'admin'
dept_subtree_ids: set[int] | None = None
tenant_custom_roles_only = False
if not login_user.is_admin():
permission_level = await cls._check_list_permission(login_user)
if permission_level == 'dept_admin':
department_ids = await cls._get_dept_subtree_ids(login_user)
dept_subtree_ids = set(department_ids or [])
from bisheng.database.models.department import DepartmentDao
try:
admin_depts = await DepartmentDao.aget_user_admin_departments(
login_user.user_id,
)
except Exception:
logger.exception(
'list_roles: aget_user_admin_departments failed user=%s',
getattr(login_user, 'user_id', None),
)
admin_depts = []
if admin_depts:
permission_level = 'dept_admin'
subtree_ids: set[int] = set()
for d in admin_depts:
path = getattr(d, 'path', None) or ''
if not path:
if getattr(d, 'id', None):
subtree_ids.add(int(d.id))
continue
try:
rows = await DepartmentDao.aget_subtree_ids(path)
except Exception:
logger.exception(
'list_roles: aget_subtree_ids failed path=%s', path,
)
rows = []
for row in rows or []:
if isinstance(row, (list, tuple)):
subtree_ids.add(int(row[0]))
else:
subtree_ids.add(int(row))
if not subtree_ids:
subtree_ids = {int(d.id) for d in admin_depts if getattr(d, 'id', None)}
department_ids = list(subtree_ids)
dept_subtree_ids = subtree_ids
else:
try:
from bisheng.permission.domain.services.permission_service import (
PermissionService,
)
is_tenant_admin = await PermissionService.check(
user_id=login_user.user_id,
relation='admin',
object_type='tenant',
object_id=str(login_user.tenant_id),
login_user=login_user,
)
except Exception:
logger.exception(
'list_roles: tenant admin check failed user=%s',
getattr(login_user, 'user_id', None),
)
is_tenant_admin = False
if is_tenant_admin:
permission_level = 'tenant_admin'
tenant_custom_roles_only = True
else:
permission_level = 'regular'
roles = await RoleDao.aget_visible_roles(
tenant_id=login_user.tenant_id,
@@ -166,19 +225,22 @@ class RoleService:
page=page,
limit=limit,
department_ids=department_ids,
tenant_custom_roles_only=tenant_custom_roles_only,
)
total = await RoleDao.acount_visible_roles(
tenant_id=login_user.tenant_id,
keyword=keyword,
department_ids=department_ids,
tenant_custom_roles_only=tenant_custom_roles_only,
)
# Get user counts
role_ids = [r.id for r in roles]
user_counts = await RoleDao.aget_user_count_by_role_ids(role_ids)
# Get department names
# Get department names + full scope path (root → leaf)
dept_names = await cls._get_department_names(roles)
scope_paths = await cls._department_scope_paths_for_roles(roles)
creator_names = await cls._get_creator_names(roles)
# Build response
@@ -193,6 +255,7 @@ class RoleService:
role_type=role.role_type,
department_id=role.department_id,
department_name=dept_names.get(role.department_id),
department_scope_path=scope_paths.get(role.department_id) if role.department_id else None,
quota_config=role.quota_config,
remark=role.remark,
user_count=user_counts.get(role.id, 0),
@@ -225,6 +288,7 @@ class RoleService:
user_counts = await RoleDao.aget_user_count_by_role_ids([role_id])
dept_names = await cls._get_department_names([role])
scope_paths = await cls._department_scope_paths_for_roles([role])
creator_names = await cls._get_creator_names([role])
permission_level = await cls._get_permission_level(login_user)
@@ -241,6 +305,7 @@ class RoleService:
role_type=role.role_type,
department_id=role.department_id,
department_name=dept_names.get(role.department_id),
department_scope_path=scope_paths.get(role.department_id) if role.department_id else None,
quota_config=role.quota_config,
remark=role.remark,
user_count=user_counts.get(role.id, 0),
@@ -296,7 +361,8 @@ class RoleService:
role.quota_config = req.quota_config
if req.remark is not None:
role.remark = req.remark
if req.department_id is not None:
# 允许显式清空为全局作用域(null);不能仅用 ``is not None`` 否则无法从部门改回全局
if 'department_id' in req.model_fields_set:
role.department_id = req.department_id
return await RoleDao.update_role(role)
@@ -347,7 +413,7 @@ class RoleService:
db_role.quota_config = req.quota_config
if req.remark is not None:
db_role.remark = req.remark
if req.department_id is not None:
if 'department_id' in req.model_fields_set:
db_role.department_id = req.department_id
session.add(db_role)
@@ -403,10 +469,11 @@ class RoleService:
await cls._check_role_permission(login_user)
await cls._ensure_role_scope_access(role, login_user, for_mutation=True)
normalized = cls._normalize_menu_ids(menu_ids)
await RoleAccessDao.update_role_access_all(
role_id=role_id,
access_type=AccessType.WEB_MENU,
access_ids=menu_ids,
access_ids=normalized,
)
@classmethod
@@ -430,7 +497,8 @@ class RoleService:
role_ids=[role_id],
access_type=AccessType.WEB_MENU,
)
return [r.third_id for r in records]
# system_config 仅超管/部门管理员通过身份下发,不可通过自定义角色分配
return [r.third_id for r in records if r.third_id != 'system_config']
# ── Permission helpers ──
@@ -439,10 +507,17 @@ class RoleService:
"""Determine user's permission level for role management.
Returns: 'admin', 'tenant_admin', 'dept_admin', or 'regular'.
**部门管理员优先于租户管理员**:若先判 ``tenant:admin``,则兼任部门管理员的账号
会拿到租户级角色列表(全租户自定义角色可编辑),与「按部门子树管理角色」PRD 冲突。
"""
if login_user.is_admin():
return 'admin'
dept_ids = await cls._get_user_admin_dept_ids(login_user)
if dept_ids:
return 'dept_admin'
try:
from bisheng.permission.domain.services.permission_service import PermissionService
is_tenant_admin = await PermissionService.check(
@@ -457,10 +532,6 @@ class RoleService:
except Exception as e:
logger.warning('PermissionService.check failed for user %d: %s', login_user.user_id, e)
dept_ids = await cls._get_user_admin_dept_ids(login_user)
if dept_ids:
return 'dept_admin'
return 'regular'
@classmethod
@@ -505,9 +576,12 @@ class RoleService:
try:
from bisheng.database.models.department import DepartmentDao
depts = await DepartmentDao.aget_user_admin_departments(login_user.user_id)
return [d.id for d in depts] if depts else []
except Exception as e:
logger.debug('Failed to query admin departments for user %d: %s', login_user.user_id, e)
return [int(d.id) for d in depts] if depts else []
except Exception:
logger.exception(
'Failed to query admin departments for user %s',
getattr(login_user, 'user_id', None),
)
return []
@classmethod
@@ -518,13 +592,23 @@ class RoleService:
admin_depts = await DepartmentDao.aget_user_admin_departments(login_user.user_id)
if not admin_depts:
return None
all_ids = set()
all_ids: set[int] = set()
for dept in admin_depts:
subtree = await DepartmentDao.aget_subtree_ids(dept.path)
all_ids.update(subtree)
path = getattr(dept, 'path', None) or ''
if not path:
continue
subtree = await DepartmentDao.aget_subtree_ids(path)
for row in subtree or []:
if isinstance(row, (list, tuple)):
all_ids.add(int(row[0]))
else:
all_ids.add(int(row))
return list(all_ids)
except Exception as e:
logger.debug('Failed to query dept subtree for user %d: %s', login_user.user_id, e)
except Exception:
logger.exception(
'Failed to query dept subtree for user %s',
getattr(login_user, 'user_id', None),
)
return None
@classmethod
@@ -541,6 +625,42 @@ class RoleService:
logger.debug('Failed to query department names: %s', e)
return {}
@classmethod
async def _department_scope_paths_for_roles(cls, roles) -> dict:
"""Full path labels ``根 / … / 作用域部门`` from ``Department.path`` (not UI-trimmed tree)."""
dept_ids = [int(r.department_id) for r in roles if r.department_id]
unique = sorted(set(dept_ids))
if not unique:
return {}
try:
from bisheng.database.models.department import DepartmentDao
depts = await DepartmentDao.aget_by_ids(unique)
if not depts:
return {}
seg_ids: set[int] = set()
for d in depts:
for seg in (getattr(d, 'path', None) or '').strip('/').split('/'):
if seg.isdigit():
seg_ids.add(int(seg))
id_to_name = {d.id: d.name for d in depts}
missing = seg_ids - set(id_to_name.keys())
if missing:
extra = await DepartmentDao.aget_by_ids(list(missing))
for d in extra or []:
id_to_name[d.id] = d.name
out: dict = {}
for d in depts:
ids = [
int(s) for s in (getattr(d, 'path', None) or '').strip('/').split('/')
if s.isdigit()
]
parts = [id_to_name.get(i) for i in ids if id_to_name.get(i)]
out[d.id] = ' / '.join(parts) if parts else (d.name or '')
return out
except Exception as e:
logger.debug('Failed to build department scope paths: %s', e)
return {}
@classmethod
async def _validate_department(cls, department_id: int) -> None:
"""Validate department_id exists and is active."""
@@ -557,7 +677,15 @@ class RoleService:
@classmethod
def _normalize_menu_ids(cls, menu_ids: List[str]) -> List[str]:
return list(dict.fromkeys(str(menu_id) for menu_id in menu_ids))
"""Deduplicate menu keys; strip system_config (reserved for super-admin / dept-admin)."""
out: list[str] = []
for menu_id in menu_ids:
s = str(menu_id)
if s == 'system_config':
continue
if s not in out:
out.append(s)
return out
@classmethod
async def _replace_menu_access_in_session(
+60 -26
View File
@@ -10,7 +10,7 @@ from captcha.image import ImageCaptcha
from fastapi import APIRouter, Depends, HTTPException, Query, Body, Request, UploadFile, File
from fastapi.security import OAuth2PasswordBearer
from loguru import logger
from sqlmodel import select
from sqlmodel import col, select
from bisheng.api.services.audit_log import AuditLogService
from bisheng.api.v1.schemas import resp_200, CreateUserReq
@@ -18,9 +18,9 @@ from bisheng.common.errcode.http_error import UnAuthorizedError, NotFoundError
from bisheng.common.errcode.user import (UserNotPasswordError, UserValidateError, UserPasswordError, UserForbiddenError)
from bisheng.common.services.config_service import settings
from bisheng.core.cache.redis_manager import get_redis_client, get_redis_client_sync
from bisheng.core.database import get_sync_db_session
from bisheng.core.database import get_async_db_session, get_sync_db_session
from bisheng.database.constants import AdminRole, DefaultRole
from bisheng.database.models.department import DepartmentDao
from bisheng.database.models.department import DepartmentDao, UserDepartment
from bisheng.database.models.group import GroupDao
from bisheng.database.models.mark_task import MarkTaskDao
from bisheng.database.models.role import Role, RoleCreate, RoleDao, RoleUpdate
@@ -94,13 +94,12 @@ async def sso(*, request: Request, user: UserCreate, auth_jwt: AuthJwt = Depends
raise ValueError('Interface not supported')
def get_error_password_key(username: str):
return USER_PASSWORD_ERROR.format(username)
def get_error_password_key(user_id: int) -> str:
return USER_PASSWORD_ERROR.format(int(user_id))
def clear_error_password_key(username: str):
# Count of cleanup password errors
error_key = get_error_password_key(username)
def clear_error_password_key(user_id: int):
error_key = get_error_password_key(user_id)
get_redis_client_sync().delete(error_key)
@@ -134,12 +133,15 @@ async def get_info(login_user: LoginUser = Depends(LoginUser.get_login_user)):
db_user = await UserDao.aget_user(user_id)
if not db_user:
raise NotFoundError()
role, web_menu = await login_user.get_roles_web_menu(db_user)
admin_group = await UserGroupDao.aget_user_admin_group(user_id)
admin_group = [one.group_id for one in admin_group]
dept_admin_depts = await DepartmentDao.aget_user_admin_departments(user_id)
is_department_admin = bool(dept_admin_depts)
role, web_menu = await login_user.get_roles_web_menu(
db_user,
is_department_admin=is_department_admin,
)
can_manage_user_groups = bool(login_user.is_admin() or is_department_admin)
return resp_200(await UserService.build_user_read(
db_user,
@@ -157,6 +159,30 @@ async def logout(auth_jwt: AuthJwt = Depends()):
return resp_200()
async def _department_admin_scoped_user_ids(user_id: int) -> Optional[List[int]]:
"""部门管理员:返回其管辖部门子树内出现过的 user_id;非部门管理员返回 None。"""
admin_depts = await DepartmentDao.aget_user_admin_departments(user_id)
if not admin_depts:
return None
internal_ids: set[int] = set()
for dept in admin_depts:
path = getattr(dept, 'path', None) or ''
if not path:
continue
subtree = await DepartmentDao.aget_subtree_ids(path)
for did in subtree:
internal_ids.add(int(did))
if not internal_ids:
return []
async with get_async_db_session() as session:
stmt = select(UserDepartment.user_id).where(
col(UserDepartment.department_id).in_(list(internal_ids)),
)
result = await session.exec(stmt)
rows = result.all()
return list({int(uid) for uid in rows})
@router.get('/user/list', status_code=201)
async def list_user(*,
name: Optional[str] = None,
@@ -168,26 +194,34 @@ async def list_user(*,
groups = group_id
roles = role_id
user_admin_groups = []
dept_scope_user_ids: Optional[List[int]] = None
if not login_user.is_admin():
# Query if you are an administrator of another user group under
user_admin_groups = UserGroupDao.get_user_admin_group(login_user.user_id)
user_admin_groups = [one.group_id for one in user_admin_groups]
groups = user_admin_groups
# Not an administrator of any user group does not have permission to view
if not groups:
raise HTTPException(status_code=500, detail="Quit that! You don't have rights to view this.")
# Filter bygroup_idand administrator permissionsgroupsDoing Intersections
if group_id:
groups = list(set(groups) & set(group_id))
if not groups:
# 无用户组管理权时,允许部门管理员按部门子树查看用户
dept_scope_user_ids = await _department_admin_scoped_user_ids(login_user.user_id)
if dept_scope_user_ids is None:
raise HTTPException(status_code=500, detail="Quit that! You don't have rights to view this.")
# Query roles under user groups, Intersect with the role filter to get the role that really needs to be queriedID
group_roles = RoleDao.get_role_by_groups(groups, None, 0, 0)
if role_id:
roles = list(set(role_id) & set([one.id for one in group_roles]))
else:
# Filter bygroup_idand administrator permissionsgroupsDoing Intersections
if group_id:
groups = list(set(groups) & set(group_id))
if not groups:
raise HTTPException(status_code=500, detail="Quit that! You don't have rights to view this.")
# Query roles under user groups, Intersect with the role filter to get the role that really needs to be queriedID
group_roles = RoleDao.get_role_by_groups(groups, None, 0, 0)
if role_id:
roles = list(set(role_id) & set([one.id for one in group_roles]))
# Users filtered by user groups and rolesid
user_ids = []
if groups:
if dept_scope_user_ids is not None:
if not dept_scope_user_ids:
return resp_200({'data': [], 'total': 0})
user_ids = dept_scope_user_ids
elif groups:
# Query users under user groupsID
groups_user_ids = UserGroupDao.get_groups_user(groups)
if not groups_user_ids:
@@ -302,7 +336,7 @@ async def update(*,
db_user.delete = user.delete
if db_user.delete == 0: # Enable User
# Count of cleanup password errors
clear_error_password_key(db_user.user_name)
clear_error_password_key(db_user.user_id)
with get_sync_db_session() as session:
session.add(db_user)
session.commit()
@@ -652,7 +686,7 @@ async def reset_password(
user_info.password_update_time = datetime.now()
UserDao.update_user(user_info)
clear_error_password_key(user_info.user_name)
clear_error_password_key(user_info.user_id)
return resp_200()
@@ -678,20 +712,20 @@ async def change_password(*,
user_info.password_update_time = datetime.now()
UserDao.update_user(user_info)
clear_error_password_key(user_info.user_name)
clear_error_password_key(user_info.user_id)
return resp_200()
@router.post('/user/change_password_public', status_code=200)
async def change_password_public(*,
username: str = Body(embed=True),
person_id: str = Body(embed=True),
password: str = Body(embed=True),
new_password: str = Body(embed=True)):
"""
Not Logged-In Users Change my password
"""
user_info = UserDao.get_user_by_username(username)
user_info = await UserDao.aget_by_external_id((person_id or '').strip())
if not user_info.password:
return UserValidateError.return_resp()
@@ -702,7 +736,7 @@ async def change_password_public(*,
user_info.password_update_time = datetime.now()
UserDao.update_user(user_info)
clear_error_password_key(username)
clear_error_password_key(user_info.user_id)
return resp_200()
+1 -1
View File
@@ -1,4 +1,4 @@
# Stores the number of times a user's password is incorrect.keyareusername
# 登录密码错误次数;占位符为 user_id(允许重名后不再用 user_name
USER_PASSWORD_ERROR = 'user_password_error:{}'
# Store current user logged incookie, keyIs Userid
USER_CURRENT_SESSION = 'user_current_session:{}'
+33 -6
View File
@@ -2,7 +2,7 @@ from datetime import datetime
from typing import List, Optional
from pydantic import field_validator
from sqlalchemy import Column, DateTime, String, UniqueConstraint, func, or_, text
from sqlalchemy import Column, DateTime, String, UniqueConstraint, func, text
from sqlalchemy.orm import selectinload
from sqlmodel import Field, select, Relationship, col
@@ -16,7 +16,7 @@ from bisheng.user.domain.models.user_role import UserRole
class UserBase(SQLModelSerializable):
user_name: str = Field(index=True, unique=True)
user_name: str = Field(index=True)
email: Optional[str] = Field(default=None, index=True)
phone_number: Optional[str] = Field(default=None, index=True)
dept_id: Optional[str] = Field(default=None, index=True)
@@ -96,6 +96,7 @@ class UserLogin(UserBase):
class UserCreate(UserBase):
external_id: Optional[str] = Field(default=None, max_length=128)
password: Optional[str] = Field(default='')
captcha_key: Optional[str] = None
captcha: Optional[str] = None
@@ -149,14 +150,32 @@ class UserDao(UserBase):
return result.first()
@classmethod
async def aget_user_for_login(cls, account: str) -> User | None:
"""按用户名或 external_id(本地人员 ID)查找可登录用户"""
async def aget_users_by_username(cls, username: str) -> List[User]:
"""同名用户可能多条;用于重名校验等"""
async with get_async_db_session() as session:
statement = select(User).where(User.user_name == username)
result = await session.exec(statement)
return list(result.all())
@classmethod
async def aget_login_candidates_by_account(cls, account: str) -> List[User]:
"""登录账号仅支持 external_id(人员ID),并过滤禁用账号。"""
acc = (account or '').strip()
if not acc:
return []
async with get_async_db_session() as session:
statement = select(User).where(
or_(User.user_name == account, User.external_id == account),
User.delete == 0,
User.external_id == acc,
)
result = await session.exec(statement)
return result.first()
return list(result.all())
@classmethod
async def aget_user_for_login(cls, account: str) -> User | None:
"""兼容旧调用:仅返回首条;登录请用 ``aget_login_candidates_by_account``。"""
rows = await cls.aget_login_candidates_by_account(account)
return rows[0] if rows else None
@classmethod
def update_user(cls, user: User) -> User:
@@ -347,6 +366,14 @@ class UserDao(UserBase):
result = await session.exec(statement)
return result.first()
@classmethod
async def aget_by_external_id(cls, external_id: str) -> Optional['User']:
"""Get user by external_id globally (cross-source)."""
async with get_async_db_session() as session:
statement = select(User).where(User.external_id == external_id)
result = await session.exec(statement)
return result.first()
@classmethod
async def aget_by_source(cls, source: str, tenant_id: int) -> List['User']:
"""Get all users from a given source within a tenant (for reconcile)."""
@@ -25,6 +25,12 @@ from ..models.user_role import UserRoleDao
logger = logging.getLogger(__name__)
# 部门管理员:工作台 + 管理后台全量菜单(含路由用的 sys、仅 UI 的 log/system_config
_DEPARTMENT_ADMIN_WEB_MENU_FULL = frozenset(
{e.value for e in WebMenuResource}
| {'log', 'system_config', 'sys'}
)
# ── AccessType → ReBAC mapping (F008, AD-02) ────────────────
# Maps old RBAC AccessType to (relation, object_type) for ReBAC delegation.
# Unmapped AccessType values fall back to the legacy RoleAccessDao logic.
@@ -419,8 +425,19 @@ class LoginUser(BaseModel):
return login_user
@classmethod
async def get_roles_web_menu(cls, user: User) -> (List[int] | str, List[str]):
""" get user roles and web menu (F005: updated for v2.5 WebMenuResource) """
async def get_roles_web_menu(
cls,
user: User,
*,
is_department_admin: bool = False,
) -> (List[int] | str, List[str]):
"""Resolve role key(s) and web_menu.
- AC-13: multi-role web_menu is the **union** of each role's WEB_MENU entries.
- Department admins get workstation + admin console menus in full (PRD 2.5).
- ``system_config`` is only granted via super-admin or department-admin; it is
stripped for other users even if legacy role_access rows exist.
"""
db_user_role = await UserRoleDao.aget_user_roles(user.user_id)
role = ''
role_ids = []
@@ -433,7 +450,11 @@ class LoginUser(BaseModel):
role = role_ids
# AC-13: union of all roles' menu permissions
web_menu = await RoleAccessDao.aget_role_access(role_ids, AccessType.WEB_MENU)
web_menu = list(set([one.third_id for one in web_menu]))
web_menu = list({one.third_id for one in web_menu})
if is_department_admin:
web_menu = list(set(web_menu) | set(_DEPARTMENT_ADMIN_WEB_MENU_FULL))
else:
web_menu = [m for m in web_menu if m not in ('system_config', 'sys')]
else:
# AC-14: admin returns all WebMenuResource values (including deprecated for compat)
web_menu = [one.value for one in WebMenuResource]
@@ -7,9 +7,14 @@ import rsa
from fastapi import Request, Depends, UploadFile, HTTPException
from bisheng.common.constants.enums.telemetry import BaseTelemetryTypeEnum
from bisheng.common.errcode.user import (UserNameAlreadyExistError,
UserForbiddenError, CaptchaError, UserValidateError,
UserPasswordMaxTryError, UserPasswordExpireError, UserNameTooLongError)
from bisheng.common.errcode.user import (
UserForbiddenError,
CaptchaError,
UserValidateError,
UserPasswordMaxTryError,
UserPasswordExpireError,
UserNameTooLongError,
)
from bisheng.common.schemas.api import resp_200
from bisheng.common.schemas.telemetry.event_data_schema import UserLoginEventData
from bisheng.common.services import telemetry_service
@@ -110,10 +115,6 @@ class UserService:
"""
Create User
"""
exists_user = UserDao.get_user_by_username(req_data.user_name)
if exists_user:
# Throwing an exception?
raise UserNameAlreadyExistError.http_exception()
user = User(
user_name=req_data.user_name,
password=cls.decrypt_md5_password(req_data.password),
@@ -131,13 +132,12 @@ class UserService:
return user
@staticmethod
def get_error_password_key(username: str):
return USER_PASSWORD_ERROR.format(username)
def get_error_password_key(user_id: int) -> str:
return USER_PASSWORD_ERROR.format(int(user_id))
@classmethod
async def clear_error_password_key(cls, username: str):
# Count of cleanup password errors
error_key = cls.get_error_password_key(username)
async def clear_error_password_key(cls, user_id: int):
error_key = cls.get_error_password_key(user_id)
(await get_redis_client()).delete(error_key)
@classmethod
@@ -159,7 +159,7 @@ class UserService:
if not password_conf.login_error_time_window or not password_conf.max_error_times:
raise UserValidateError()
# Number of errors plus1
error_key = cls.get_error_password_key(db_user.user_name)
error_key = cls.get_error_password_key(db_user.user_id)
error_num = await redis_client.aincr(error_key)
if error_num == 1:
# First time setupkeyExpiration date
@@ -179,11 +179,15 @@ class UserService:
raise CaptchaError()
db_user = User.model_validate(user)
person_id = (db_user.external_id or "").strip()
if not person_id:
raise UserValidateError(msg='Person ID is required')
existing_pid = await UserDao.aget_by_external_id(person_id)
if existing_pid:
raise UserValidateError(msg='Person ID already exists')
db_user.external_id = person_id
# check if user already exist
user_exists = await UserDao.aget_user_by_username(db_user.user_name)
if user_exists:
raise UserNameAlreadyExistError()
# 允许用户名重复;人员唯一性由 external_id / user_id 等保证
if len(db_user.user_name) > 30:
raise UserNameTooLongError()
db_user.password = cls.decrypt_md5_password(user.password)
@@ -196,8 +200,45 @@ class UserService:
db_user = await UserDao.add_user_and_admin_role(db_user)
if settings.multi_tenant.enabled:
await cls._ensure_user_default_tenant_association(db_user.user_id)
await cls._ensure_user_guest_department_membership(db_user.user_id)
return db_user
@classmethod
async def _ensure_user_guest_department_membership(cls, user_id: int) -> None:
"""注册完成后自动加入“临时访客”部门(主部门)。"""
from bisheng.database.models.department import Department, UserDepartment
from sqlmodel import select
guest_dept_id = 'BS@guest'
async with get_async_db_session() as session:
dept = (
await session.exec(
select(Department).where(
Department.dept_id == guest_dept_id,
Department.status == 'active',
)
)
).first()
if not dept:
return
exists = (
await session.exec(
select(UserDepartment).where(
UserDepartment.user_id == user_id,
UserDepartment.department_id == dept.id,
)
)
).first()
if exists:
return
session.add(UserDepartment(
user_id=user_id,
department_id=dept.id,
is_primary=1,
source='local',
))
await session.commit()
@classmethod
async def _ensure_user_default_tenant_association(cls, user_id: int) -> None:
"""When multi-tenant is on, every user must have at least one ``user_tenant`` row to log in."""
@@ -220,17 +261,31 @@ class UserService:
if not user.captcha_key or not await verify_captcha(user.captcha, user.captcha_key):
raise CaptchaError()
# get user info支持用户名或本地人员 external_id 登录)
db_user = await UserDao.aget_user_for_login(user.user_name)
# verify user exists
# 支持用户名或 external_id;重名时对候选用户依次校验密码
candidates = await UserDao.aget_login_candidates_by_account(user.user_name)
if not candidates:
return UserValidateError.return_resp()
password = cls.decrypt_md5_password(user.password)
db_user = None
for c in candidates:
if c.delete == 1:
continue
try:
await cls.judge_user_password(c, password)
db_user = c
break
except UserPasswordExpireError:
raise
except UserPasswordMaxTryError:
raise
except UserValidateError:
# 该候选用户密码不匹配,尝试下一个同名账号
continue
if not db_user:
return UserValidateError.return_resp()
if db_user.delete == 1:
raise UserForbiddenError()
# verify password
password = cls.decrypt_md5_password(user.password)
await cls.judge_user_password(db_user, password)
await cls.clear_error_password_key(db_user.user_id)
# Multi-tenant login flow
tenant_id = None
@@ -70,7 +70,7 @@ async def _ensure_manage_user_groups(login_user) -> None:
async def _ensure_mutate_group(login_user, group: Group) -> None:
"""更新成员等:仅系统超管或该用户组的创建者。"""
"""修改用户组元数据(名称、可见性等):仅系统超管或创建者。"""
if _is_admin(login_user):
return
if group.create_user == login_user.user_id:
@@ -78,6 +78,17 @@ async def _ensure_mutate_group(login_user, group: Group) -> None:
raise UserGroupPermissionDeniedError()
async def _ensure_mutate_group_members(login_user, group: Group) -> None:
"""增删普通成员:超管、创建者,或「公开组」下的部门管理员(与组织成员编辑 PRD 一致)。"""
if _is_admin(login_user):
return
if group.create_user == login_user.user_id:
return
if getattr(group, 'visibility', None) == 'public' and await _is_department_admin(login_user):
return
raise UserGroupPermissionDeniedError()
async def _ensure_create_group(login_user) -> None:
await _ensure_manage_user_groups(login_user)
@@ -109,10 +120,11 @@ class UserGroupService:
@classmethod
async def _list_manageable_groups(cls, login_user) -> List[Group]:
"""Groups the caller may mutate.
"""Groups whose **membership** the caller may assign (编辑人员用户组多选).
System admins may manage any group. Non-admins may only manage
groups they created themselves.
System admins: any group. Department admins: public groups + groups they
created (private groups created by others remain out of scope). Other
users: only groups they created.
"""
if _is_admin(login_user):
groups, _ = await GroupDao.aget_all_groups(1, 2000, '')
@@ -121,6 +133,11 @@ class UserGroupService:
visible_groups, _ = await GroupDao.aget_visible_groups(
login_user.user_id, 1, 2000, '',
)
if await _is_department_admin(login_user):
return [
g for g in visible_groups
if g.visibility == 'public' or g.create_user == login_user.user_id
]
return [g for g in visible_groups if g.create_user == login_user.user_id]
@classmethod
@@ -282,7 +299,7 @@ class UserGroupService:
group = await GroupDao.aget_by_id(group_id)
if not group:
raise UserGroupNotFoundError()
await _ensure_mutate_group(login_user, group)
await _ensure_mutate_group_members(login_user, group)
if not user_ids:
return
@@ -306,7 +323,7 @@ class UserGroupService:
group = await GroupDao.aget_by_id(group_id)
if not group:
raise UserGroupNotFoundError()
await _ensure_mutate_group(login_user, group)
await _ensure_mutate_group_members(login_user, group)
desired_set = {int(x) for x in desired_user_ids if x and int(x) > 0}
current_ids = set(await UserGroupDao.aget_plain_member_user_ids(group_id))
@@ -361,7 +378,7 @@ class UserGroupService:
group = await GroupDao.aget_by_id(group_id)
if not group:
raise UserGroupNotFoundError()
await _ensure_mutate_group(login_user, group)
await _ensure_mutate_group_members(login_user, group)
row = await UserGroupDao.aget_plain_member_row(group_id, user_id)
if not row:
@@ -2,6 +2,7 @@ import type { AxiosResponse } from 'axios';
import * as endpoints from './api-endpoints';
import * as config from '~/types/chat/config';
import request from '~/api/request';
import { getPlatformAdminPanelUrl } from '~/utils/platformAdminUrl';
import * as r from '~/types/chat/roles';
import * as s from '~/types/chat/schemas';
import type * as t from '~/types/chat/types';
@@ -125,15 +126,20 @@ export function getSearchEnabled(): Promise<boolean> {
export function getUser(): Promise<t.TUser> {
return request.get(endpoints.user()).then(res => {
const { user_id, user_name, create_time, update_time, role, web_menu, avatar } = res.data;
if (role !== 'admin' && !web_menu.includes('frontend')) {
if (!web_menu.includes('backend')) {
// No frontend or backend permission — logout to avoid infinite redirect loop
// between the two systems.
const canWorkspace =
web_menu.includes('frontend') ||
web_menu.includes('workstation');
const canManagement =
web_menu.includes('backend') ||
web_menu.includes('admin');
if (role !== 'admin' && !canWorkspace) {
if (!canManagement) {
// 无工作台也无管理端菜单 — 退出并回管理端登录
logout().catch(() => { });
location.href = `${location.origin}${__APP_ENV__.BISHENG_HOST}`;
location.href = getPlatformAdminPanelUrl();
return Promise.reject(new Error('no_permission'));
}
location.href = `${location.origin}${__APP_ENV__.BISHENG_HOST}?error=90002` // workspace useErrorPrompt
location.href = getPlatformAdminPanelUrl('error=90002');
}
return {
"_id": user_id,
+2 -1
View File
@@ -2,6 +2,7 @@
import axios, { AxiosError, AxiosRequestConfig } from 'axios';
import i18next from "i18next";
import { setTokenHeader } from '~/api/chat/headers-helpers';
import { getPlatformAdminPanelUrl } from '~/utils/platformAdminUrl';
import * as endpoints from '~/api/chat/api-endpoints';
import type * as t from '~/types/chat/types';
@@ -135,7 +136,7 @@ customAxios.interceptors.response.use(
if (import.meta.env.MODE === 'production') {
localStorage.setItem('LOGIN_PATHNAME', location.pathname)
location.href = `${location.origin}${__APP_ENV__.BISHENG_HOST}`
location.href = getPlatformAdminPanelUrl()
}
// } else {
// if (location.pathname.indexOf('login') === -1) {
@@ -8,6 +8,7 @@ import { useLocalize } from '~/hooks';
import { useAuthContext } from '~/hooks/AuthContext';
import useAvatar from '~/hooks/Messages/useAvatar';
import store from '~/store';
import { getPlatformAdminPanelUrl } from '~/utils/platformAdminUrl';
import MyKnowledgeView from '../Chat/Input/Files/MyKnowledgeView';
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger } from '../ui';
import Settings from './Settings';
@@ -90,7 +91,7 @@ function AccountSettings() {
</div>
</DropdownMenuTrigger>
<DropdownMenuContent className='w-60 rounded-2xl'>
{user?.plugins?.includes('backend') && <a href={__APP_ENV__.BISHENG_HOST} target='_blank'>
{(user?.plugins?.includes('backend') || user?.plugins?.includes('admin')) && <a href={getPlatformAdminPanelUrl()} target='_blank'>
<DropdownMenuItem className='select-item text-sm font-normal'>
<GanttChartIcon className="icon-md" />
{localize('com_nav_admin_panel')}
@@ -22,6 +22,7 @@ import {
import { TAuthConfig, TUserContext, TAuthContext, TResError } from '~/common';
import useTimeout from './useTimeout';
import store from '~/store';
import { getPlatformAdminPanelUrl } from '~/utils/platformAdminUrl';
const AuthContext = createContext<TAuthContext | undefined>(undefined);
@@ -96,7 +97,7 @@ const AuthContextProvider = ({
token: undefined,
isAuthenticated: false,
// user: undefined,
redirect: `${location.origin}${__APP_ENV__.BISHENG_HOST}` // data.redirect ?? bsConfig?.host,
redirect: getPlatformAdminPanelUrl(),
});
},
onError: (error) => {
@@ -150,6 +151,7 @@ const AuthContextProvider = ({
useEffect(() => {
if (userQuery.data) {
setUser(userQuery.data);
setIsAuthenticated(true);
} else if (userQuery.isError) {
doSetError((userQuery.error as Error).message);
// navigate(`/${__APP_ENV__.BISHENG_HOST}`, { replace: true });
@@ -14,6 +14,7 @@ import { useGetBsConfig } from '~/hooks/queries/data-provider';
import { useAuthContext, useLocalize } from '~/hooks';
import store from '~/store';
import { cn } from '~/utils';
import { getPlatformAdminPanelUrl } from '~/utils/platformAdminUrl';
import { UserPopMenu } from './UserPopMenu';
// Module-level storage for the last visited path per sidebar section.
@@ -118,8 +119,8 @@ function Sidebar() {
</div>
<div className="flex flex-col gap-4 items-center">
{user?.plugins?.includes('backend') && (
<a href={__APP_ENV__.BISHENG_HOST} target='_blank' rel="noreferrer">
{(user?.plugins?.includes('backend') || user?.plugins?.includes('admin')) && (
<a href={getPlatformAdminPanelUrl()} target='_blank' rel="noreferrer">
<div title={localize('com_nav_admin_panel')} className="p-3 rounded-lg hover:bg-[#e6edfc] transition-colors">
<MonitorIcon className="size-5 text-[#818181]" />
</div>
+9 -1
View File
@@ -19,7 +19,7 @@ export default function Root() {
return savedNavVisible !== null ? JSON.parse(savedNavVisible) : true;
});
const { isAuthenticated, logout } = useAuthContext();
const { isAuthenticated, isUserLoading, logout } = useAuthContext();
const assistantsMap = useAssistantsMap({ isAuthenticated });
const agentsMap = useAgentsMap({ isAuthenticated });
const fileMap = useFileMap({ isAuthenticated });
@@ -27,6 +27,14 @@ export default function Root() {
if (isUserLoading) {
return (
<div className="flex h-full w-full items-center justify-center bg-white text-sm text-gray-500 dark:bg-gray-900 dark:text-gray-400">
Loading
</div>
);
}
if (!isAuthenticated) {
return null;
}
@@ -0,0 +1,15 @@
/**
* 本地开发:工作台在 :4001、管理端在 :3001,相对路径 /admin 会错误地指向 4001。
* 生产:通常同源反代,用当前 origin + BISHENG_HOST 即可。
*/
export function getPlatformAdminPanelUrl(search?: string): string {
const path = __APP_ENV__.BISHENG_HOST || '/admin';
const platformOrigin = (__APP_ENV__ as { PLATFORM_ORIGIN?: string }).PLATFORM_ORIGIN?.replace(/\/$/, '') || '';
const origin = platformOrigin || (typeof location !== 'undefined' ? location.origin : '');
const suffix = path.startsWith('/') ? path : `/${path}`;
let url = `${origin}${suffix}`;
if (search) {
url += search.startsWith('?') ? search : `?${search}`;
}
return url;
}
+2
View File
@@ -13,5 +13,7 @@ interface ImportMeta {
declare const __APP_ENV__: {
BASE_URL: string;
BISHENG_HOST?: string;
/** 开发时管理端 origin,与 BASE_URL 不同端口时用于拼管理后台链接 */
PLATFORM_ORIGIN?: string;
[key: string]: any;
};
+6 -5
View File
@@ -9,13 +9,14 @@ import { VitePWA } from 'vite-plugin-pwa';
import { viteStaticCopy } from 'vite-plugin-static-copy';
import { createHtmlPlugin } from 'vite-plugin-html';
const app_env = {
BASE_URL: '/workspace',
BISHENG_HOST: '/admin'
}
// https://vitejs.dev/config/
export default defineConfig(({ command, mode }) => {
const app_env = {
BASE_URL: '/workspace',
// 须为路由合法 path(见 routes/index.tsx);跨端口用 PLATFORM_ORIGIN + href 工具函数
BISHENG_HOST: '/admin',
PLATFORM_ORIGIN: command === 'serve' ? 'http://localhost:3001' : '',
};
const env = loadEnv(mode, process.cwd(), 'VITE_');
const apiProxyTarget =
env.VITE_PROXY_TARGET || process.env.VITE_PROXY_TARGET || 'http://127.0.0.1:7860';
@@ -82,7 +82,9 @@
},
"login": {
"slogen": "Convenient, Flexible, Reliable Enterprise-Level Large Model Application Development Platform",
"account": "Email",
"account": "Account",
"personId": "Person ID",
"userName": "Username",
"password": "Password",
"confirmPassword": "Confirm Password",
"noAccountRegister": "Don't have an account? Register",
@@ -91,8 +93,11 @@
"registerButton": "Register",
"document": "Documentation",
"pleaseEnterAccount": "Please enter your account",
"pleaseEnterPersonId": "Please enter person ID",
"pleaseEnterUserName": "Please enter username",
"pleaseEnterPassword": "Please enter your password",
"accountTooShort": "Account is too short",
"personIdTooShort": "Person ID is too short",
"passwordTooShort": "Password is too short, at least 8 characters",
"passwordError": "The password must include upper and lower case letters, numbers, and characters!",
"passwordMismatch": "Passwords do not match",
@@ -1809,16 +1814,21 @@
"department.confirmDelete": "Are you sure you want to delete this department? It will be archived.",
"department.permanentDelete": "Permanently Delete",
"department.confirmPermanentDelete": "Are you sure you want to permanently delete this department? All associated knowledge spaces, applications, etc. will be deleted. This action cannot be undone.",
"department.restore": "Restore Department",
"department.confirmRestore": "Are you sure you want to restore this department? It will become editable again.",
"department.archivedNotice": "This department has been archived and cannot be edited. You may permanently delete it.",
"department.archivedTag": "(Archived)",
"department.settings": "Department Settings",
"department.members": "Members",
"department.trafficControl": "Traffic Control",
"department.deptFlowControl": "Department Traffic Control",
"department.createLocalUser": "Create local account",
"department.localUserNameRequired": "Username is required",
"department.personIdRequired": "Person ID is required",
"department.localUserRolesRequired": "Select at least one assignable role",
"department.primaryDeptFixed": "Primary department (fixed)",
"department.assignRoles": "Assign roles",
"department.assignRolesOptionalHint": "Optional. If none are selected, only the departments default roles (from Department settings) apply. The list shows roles assignable in this department only.",
"department.localUserCreatedTip": "Created user {{name}}, person ID: {{person}}. Account and initial password were copied to the clipboard.",
"department.personId": "Person ID",
"department.updateTime": "Updated",
@@ -1841,7 +1851,9 @@
"department.selectDept": "Select a department from the left panel",
"department.memberCount": "Members",
"department.userGroups": "User Groups",
"department.memberEditUserGroupsEmpty": "No user groups to assign. Department admins can add users to public groups and groups they created.",
"department.roles": "Roles",
"department.deptAdminRoleName": "Department admin",
"department.enabled": "Enabled",
"department.disabled": "Disabled",
"department.all": "All",
@@ -83,7 +83,9 @@
"login": {
"slogen": "便利・柔軟・信頼できるエンタープライズ向け大規模モデルアプリ開発プラットフォーム",
"bottomText": "文擎毕昇",
"account": "メールアドレス",
"account": "アカウント",
"personId": "個人 ID",
"userName": "ユーザー名",
"password": "パスワード",
"confirmPassword": "パスワード確認",
"noAccountRegister": "アカウントがありませんか?新規登録",
@@ -92,8 +94,11 @@
"registerButton": "登録",
"document": "ドキュメント",
"pleaseEnterAccount": "アカウントを入力してください",
"pleaseEnterPersonId": "個人 ID を入力してください",
"pleaseEnterUserName": "ユーザー名を入力してください",
"pleaseEnterPassword": "パスワードを入力してください",
"accountTooShort": "アカウント名が短すぎます",
"personIdTooShort": "個人 ID が短すぎます",
"passwordTooShort": "パスワードは8文字以上で入力してください",
"passwordError": "パスワードには大文字・小文字・数字・記号を含める必要があります!",
"passwordMismatch": "2回入力したパスワードが一致しません",
@@ -1772,16 +1777,21 @@
"department.confirmDelete": "この部門を削除してよろしいですか?アーカイブされます。",
"department.permanentDelete": "完全に削除",
"department.confirmPermanentDelete": "この部門を完全に削除してよろしいですか?関連するナレッジスペース、アプリケーションなどもすべて削除されます。この操作は元に戻せません。",
"department.restore": "部門を復元",
"department.confirmRestore": "この部門を復元しますか?復元後は再び編集可能になります。",
"department.archivedNotice": "この部門はアーカイブ済みのため編集できません。完全に削除することができます。",
"department.archivedTag": "(アーカイブ済み)",
"department.settings": "部門設定",
"department.members": "メンバー一覧",
"department.trafficControl": "トラフィック制御",
"department.deptFlowControl": "部門全体のトラフィック制御",
"department.createLocalUser": "ローカルアカウント作成",
"department.localUserNameRequired": "ユーザー名を入力してください",
"department.personIdRequired": "個人 ID を入力してください",
"department.localUserRolesRequired": "割り当て可能なロールを1つ以上選択してください",
"department.primaryDeptFixed": "主所属部門(固定)",
"department.assignRoles": "ロール割当",
"department.assignRolesOptionalHint": "任意です。未選択の場合は部門設定の「デフォルトロール」のみ付与されます。一覧は当該部門で付与可能なロールのみです。",
"department.localUserCreatedTip": "ユーザー {{name}} を作成しました。個人 ID:{{person}}。アカウント情報と初期パスワードをクリップボードにコピーしました。",
"department.personId": "個人 ID",
"department.updateTime": "更新日時",
@@ -1804,7 +1814,9 @@
"department.selectDept": "左側のパネルから部門を選択してください",
"department.memberCount": "メンバー数",
"department.userGroups": "ユーザーグループ",
"department.memberEditUserGroupsEmpty": "選択できるユーザーグループがありません。部門管理者は「公開」グループおよび自身が作成したグループに追加できます。",
"department.roles": "ロール",
"department.deptAdminRoleName": "部門管理者",
"department.enabled": "有効",
"department.disabled": "無効",
"department.all": "すべて",
@@ -84,7 +84,9 @@
"login": {
"slogen": "便捷、灵活、可靠的企业级大模型应用开发平台",
"bottomText": "文擎毕昇",
"account": "邮箱",
"account": "账号",
"personId": "人员 ID",
"userName": "用户名",
"password": "密码",
"confirmPassword": "确认密码",
"noAccountRegister": "没有账号?注册",
@@ -93,8 +95,11 @@
"registerButton": "注 册",
"document": "文档",
"pleaseEnterAccount": "请填写账号",
"pleaseEnterPersonId": "请填写人员 ID",
"pleaseEnterUserName": "请填写用户名",
"pleaseEnterPassword": "请填写密码",
"accountTooShort": "账号过短",
"personIdTooShort": "人员 ID 过短",
"passwordTooShort": "请填写密码,至少8位",
"passwordError": "密码必须包含大小写字母、数字和字符!",
"passwordMismatch": "两次密码不一致",
@@ -1766,16 +1771,21 @@
"department.confirmDelete": "确认删除该部门?删除后部门将被归档。",
"department.permanentDelete": "永久删除",
"department.confirmPermanentDelete": "确认永久删除该部门?部门相关的知识空间、应用等将会同步删除,此操作不可恢复。",
"department.restore": "还原部门",
"department.confirmRestore": "确认还原该部门?还原后该部门将恢复为可编辑状态。",
"department.archivedNotice": "该部门已归档,无法编辑。可选择永久删除。",
"department.archivedTag": "(已归档)",
"department.settings": "部门设置",
"department.members": "成员列表",
"department.trafficControl": "流量控制",
"department.deptFlowControl": "部门整体流量控制",
"department.createLocalUser": "创建本地人员",
"department.localUserNameRequired": "请填写用户名",
"department.personIdRequired": "请填写人员 ID",
"department.localUserRolesRequired": "请至少选择一个可分配角色",
"department.primaryDeptFixed": "主属部门(固定)",
"department.assignRoles": "分配角色",
"department.assignRolesOptionalHint": "可选;不选时仅应用部门设置中的「默认角色」。列表仅含当前部门可分配的角色。",
"department.localUserCreatedTip": "已创建用户 {{name}},人员 ID{{person}}。账号与初始密码已复制到剪贴板。",
"department.personId": "人员 ID",
"department.updateTime": "修改时间",
@@ -1798,7 +1808,9 @@
"department.selectDept": "请在左侧选择一个部门",
"department.memberCount": "成员数",
"department.userGroups": "用户组",
"department.memberEditUserGroupsEmpty": "暂无可选用户组。部门管理员可将人员加入「公开」用户组,以及您自己创建的用户组。",
"department.roles": "角色",
"department.deptAdminRoleName": "部门管理员",
"department.enabled": "启用",
"department.disabled": "已禁用",
"department.all": "全部",
@@ -129,7 +129,22 @@ export function UserProvider({ children }: { children: ReactNode }) {
// 是否有访问后台权限
if (/^(\/\w+)?\/chat/.test(location.pathname)) return // 排除免登陆
if (res.role !== 'admin' && !web_menu.includes('backend')) {
// v2.5 角色菜单使用 admin 作为管理端父级;旧数据可能仍为 backendWebMenuResource 遗留)
const adminMenuKeys = new Set([
'backend',
'admin',
'board',
'model',
'log',
'knowledge',
'build',
'evaluation',
'system_config',
'mark_task',
])
const canAccessPlatform =
res.role === 'admin' || web_menu.some((k: string) => adminMenuKeys.has(k))
if (!canAccessPlatform) {
location.href = `${location.origin}/workspace/c/new?error=90001`;
return;
}
@@ -48,6 +48,10 @@ export async function purgeDepartmentApi(deptId: string): Promise<any> {
return await axios.delete(`/api/v1/departments/${depSeg(deptId)}/purge`)
}
export async function restoreDepartmentApi(deptId: string): Promise<any> {
return await axios.post(`/api/v1/departments/${depSeg(deptId)}/restore`)
}
// ── Move ───────────────────────────────────────────────
export async function moveDepartmentApi(
@@ -171,7 +175,7 @@ export async function getDepartmentAssignableRolesApi(
export async function createDepartmentLocalMemberApi(
deptId: string,
data: { user_name: string; password: string; role_ids: number[] }
data: { user_name: string; person_id: string; password: string; role_ids: number[] }
): Promise<{ user_id: number; user_name: string; person_id: string; dept_id: string }> {
// dept_id 放 body,避免路径含 BS@ 时部分网关/反代误解析为 404(与 POST .../{dept_id}/local-members 等价)
return await axios.post(`/api/v1/departments/local-members`, {
@@ -20,18 +20,19 @@ export async function logoutApi() {
return await axios.post(`/api/v1/user/logout`);
}
// 登录
export async function loginApi(name, pwd, captcha_key?, captcha?) {
export async function loginApi(personId, pwd, captcha_key?, captcha?) {
return await axios.post(`/api/v1/user/login`, {
user_name: name,
user_name: personId,
password: pwd,
captcha_key,
captcha,
});
}
// 注册
export async function registerApi(name, pwd, captcha_key?, captcha?) {
export async function registerApi(userName, personId, pwd, captcha_key?, captcha?) {
return await axios.post(`/api/v1/user/regist`, {
user_name: name,
user_name: userName,
external_id: personId,
password: pwd,
captcha_key,
captcha,
@@ -344,9 +345,9 @@ export async function resetPasswordApi(userId, password): Promise<any> {
/**
* 密码过期重置个人密码
*/
export async function changePasswordApi(userName, password, new_password): Promise<any> {
export async function changePasswordApi(personId, password, new_password): Promise<any> {
return axios.post(`/api/v1/user/change_password_public`, {
username: userName,
person_id: personId,
password,
new_password
});
+13 -12
View File
@@ -58,17 +58,18 @@ export default function MainLayout() {
// 重置密码
const navigator = useNavigate()
const JumpResetPage = () => {
localStorage.setItem('account', user.user_name)
localStorage.setItem('account', user.external_id || user.user_name)
navigator('/reset')
}
// 系统管理员(超管、组超管)
const isAdmin = useMemo(() => {
return user.role === 'admin'
}, [user])
// 系统超管(租户超管仍走自定义角色 web_menu;部门管理员由服务端合并全量菜单)
const isSuperAdmin = useMemo(() => user.role === "admin", [user])
const isDeptAdmin = Boolean(user.is_department_admin)
// 侧栏:数据集 / 日志 / 系统管理 — 超管与部门管理员
const isFullAdminShell = isSuperAdmin || isDeptAdmin
const isMenu = (menu) => {
return user.web_menu.includes(menu) || user.role === 'admin'
const isMenu = (menu: string) => {
return user.web_menu?.includes(menu) || isSuperAdmin
}
return <div className="flex">
@@ -100,7 +101,7 @@ export default function MainLayout() {
</Tooltip>
</TooltipProvider>
<Separator className="mx-[4px] dark:bg-[#111111]" orientation="vertical" />
{appConfig.multiTenantEnabled && <TenantSwitcher user={user} isAdmin={isAdmin} />}
{appConfig.multiTenantEnabled && <TenantSwitcher user={user} isAdmin={isSuperAdmin} />}
<SelectHover
className={"-top-4"}
triagger={
@@ -160,7 +161,7 @@ export default function MainLayout() {
</NavLink>
}
{
user.role === 'admin' && <>
isFullAdminShell && <>
<NavLink to='/dataset' className={`navlink inline-flex rounded-lg w-full px-6 hover:bg-nav-hover h-12 mb-[3.5px]`}>
<DatasetIcon className="h-6 w-6 my-[12px]" /><span className="mx-[14px] max-w-[48px] text-[14px] leading-[48px]">{t('menu.dataset')}</span>
</NavLink>
@@ -184,21 +185,21 @@ export default function MainLayout() {
</NavLink>
}
{
isAdmin && <>
isFullAdminShell && <>
<NavLink to='/log' className={`navlink inline-flex rounded-lg w-full px-6 hover:bg-nav-hover h-12 mb-[3.5px]`}>
<LogIcon className="h-6 w-6 my-[12px]" /><span className="mx-[14px] max-w-[56px] text-[14px] leading-[48px]">{t('menu.log')}</span>
</NavLink>
</>
}
{
isAdmin && <>
isFullAdminShell && <>
<NavLink to='/sys' className={`navlink inline-flex rounded-lg w-full px-6 hover:bg-nav-hover h-12 mb-[3.5px]`}>
<SystemIcon className="h-6 w-6 my-[12px]" /><span className="mx-[14px] max-w-[56px] text-[14px] leading-[48px]">{t('menu.system')}</span>
</NavLink>
</>
}
{
isAdmin && appConfig.multiTenantEnabled && <>
isSuperAdmin && appConfig.multiTenantEnabled && <>
<NavLink to='/tenant' className={`navlink inline-flex rounded-lg w-full px-6 hover:bg-nav-hover h-12 mb-[3.5px]`}>
<SystemIcon className="h-6 w-6 my-[12px]" /><span className="mx-[14px] max-w-[56px] text-[14px] leading-[48px]">{t('tenant.management')}</span>
</NavLink>
@@ -65,10 +65,10 @@ export function CreateDepartmentDialog({
})
).then((res) => {
setLoading(false)
if (res !== null) {
toast({ title: t("bs:department.create"), variant: "success" })
onCreated()
}
// captureAndAlertRequestErrorHoc 在接口失败时返回 false(不是 null)
if (res === false) return
toast({ title: t("bs:department.create"), variant: "success" })
onCreated()
})
}, [name, parentId, adminPicks, onCreated, t])
@@ -6,8 +6,8 @@ import { Label } from "@/components/bs-ui/label"
import { toast } from "@/components/bs-ui/toast/use-toast"
import {
createDepartmentLocalMemberApi,
getDepartmentAssignableRolesApi,
} from "@/controllers/API/department"
import { getRolesApi } from "@/controllers/API/user"
import { captureAndAlertRequestErrorHoc } from "@/controllers/request"
import { handleEncrypt, PWD_RULE } from "@/pages/LoginPage/utils"
import { copyText } from "@/utils"
@@ -29,26 +29,24 @@ export function CreateLocalMemberDialog({
}: CreateLocalMemberDialogProps) {
const { t } = useTranslation()
const [userName, setUserName] = useState("")
const [personId, setPersonId] = useState("")
const [password, setPassword] = useState("")
const [roles, setRoles] = useState<{ id: number; role_name: string }[]>([])
const [selected, setSelected] = useState<Set<number>>(new Set())
const [submitting, setSubmitting] = useState(false)
const loadRoles = useCallback(() => {
// 先使用稳定角色列表接口,避免部分环境 assignable-roles 404 导致下拉为空
captureAndAlertRequestErrorHoc(getRolesApi("")).then((fallback) => {
if (fallback && fallback.length > 0) {
setRoles(fallback.map((r: any) => ({
id: r.id,
role_name: r.role_name,
})))
captureAndAlertRequestErrorHoc(getDepartmentAssignableRolesApi(deptId)).then((list) => {
if (list && list.length > 0) {
setRoles(list.map((r) => ({ id: r.id, role_name: r.role_name })))
} else {
setRoles([])
}
})
}, [])
}, [deptId])
useEffect(() => {
setSelected(new Set())
loadRoles()
}, [loadRoles])
@@ -66,12 +64,12 @@ export function CreateLocalMemberDialog({
toast({ title: t("prompt"), description: t("bs:department.localUserNameRequired"), variant: "error" })
return
}
if (!PWD_RULE.test(password)) {
toast({ title: t("prompt"), description: t("system.passwordRequirements"), variant: "error" })
if (!personId.trim()) {
toast({ title: t("prompt"), description: t("bs:department.personIdRequired"), variant: "error" })
return
}
if (selected.size === 0) {
toast({ title: t("prompt"), description: t("bs:department.localUserRolesRequired"), variant: "error" })
if (!PWD_RULE.test(password)) {
toast({ title: t("prompt"), description: t("system.passwordRequirements"), variant: "error" })
return
}
setSubmitting(true)
@@ -80,6 +78,7 @@ export function CreateLocalMemberDialog({
const res = await captureAndAlertRequestErrorHoc(
createDepartmentLocalMemberApi(deptId, {
user_name: userName.trim(),
person_id: personId.trim(),
password: enc,
role_ids: Array.from(selected),
})
@@ -110,6 +109,10 @@ export function CreateLocalMemberDialog({
<Label>{t("system.username")}</Label>
<Input value={userName} onChange={(e) => setUserName(e.target.value)} className="mt-1" />
</div>
<div>
<Label>{t("bs:department.personId")}</Label>
<Input value={personId} onChange={(e) => setPersonId(e.target.value)} className="mt-1" />
</div>
<div>
<Label>{t("system.initialPassword")}</Label>
<PasswordInput value={password} onChange={(e) => setPassword(e.target.value)} className="mt-1" />
@@ -120,6 +123,9 @@ export function CreateLocalMemberDialog({
</div>
<div>
<Label>{t("bs:department.assignRoles")}</Label>
<p className="mt-1 text-xs text-muted-foreground">
{t("bs:department.assignRolesOptionalHint")}
</p>
<div className="mt-2 max-h-48 space-y-2 overflow-y-auto rounded border p-2">
{roles.length === 0 ? (
<div className="space-y-2">
@@ -12,6 +12,7 @@ import {
getDepartmentApi,
getDepartmentAssignableRolesApi,
purgeDepartmentApi,
restoreDepartmentApi,
setDepartmentAdminsApi,
updateDepartmentApi,
} from "@/controllers/API/department"
@@ -132,8 +133,13 @@ export function DepartmentSettings({ dept, tree, onChanged }: DepartmentSettings
captureAndAlertRequestErrorHoc(
deleteDepartmentApi(dept.dept_id)
).then((res) => {
if (res !== null) {
toast({ title: t("bs:department.delete"), variant: "success" })
// captureAndAlertRequestErrorHoc returns false when request failed.
if (res !== false && res !== "canceled") {
toast({
title: t("prompt"),
description: t("deleteSuccess"),
variant: "success",
})
onChanged()
}
next()
@@ -150,8 +156,34 @@ export function DepartmentSettings({ dept, tree, onChanged }: DepartmentSettings
captureAndAlertRequestErrorHoc(
purgeDepartmentApi(dept.dept_id)
).then((res) => {
if (res !== null) {
toast({ title: t("bs:department.permanentDelete"), variant: "success" })
if (res !== false && res !== "canceled") {
toast({
title: t("prompt"),
description: t("deleteSuccess"),
variant: "success",
})
onChanged()
}
next()
})
},
})
}, [dept.dept_id, onChanged, t])
const handleRestore = useCallback(() => {
bsConfirm({
title: t("bs:department.restore"),
desc: t("bs:department.confirmRestore"),
onOk: (next) => {
captureAndAlertRequestErrorHoc(
restoreDepartmentApi(dept.dept_id)
).then((res) => {
if (res !== false && res !== "canceled") {
toast({
title: t("prompt"),
description: t("save") + t("success"),
variant: "success",
})
onChanged()
}
next()
@@ -275,9 +307,14 @@ export function DepartmentSettings({ dept, tree, onChanged }: DepartmentSettings
{/* Delete / Purge */}
{isArchived && (
<div className="border-t pt-6">
<Button variant="destructive" onClick={handlePurge}>
{t("bs:department.permanentDelete")}
</Button>
<div className="flex items-center gap-2">
<Button variant="outline" onClick={handleRestore}>
{t("bs:department.restore")}
</Button>
<Button variant="destructive" onClick={handlePurge}>
{t("bs:department.permanentDelete")}
</Button>
</div>
</div>
)}
{!isArchived && !isSynced && dept.parent_id !== null && (
@@ -2,7 +2,7 @@ import { SearchInput } from "@/components/bs-ui/input"
import { cn } from "@/utils"
import { DepartmentTreeNode } from "@/types/api/department"
import { Building2, ChevronDown, ChevronRight, Plus } from "lucide-react"
import { useCallback, useEffect, useMemo, useState } from "react"
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { useTranslation } from "react-i18next"
/** 每层缩进宽度(px),侧栏内要一眼能看出父子关系 */
@@ -22,6 +22,8 @@ export function DepartmentTree({ data, selectedDeptId, onSelect, onCreateChild }
const { t } = useTranslation()
const [keyword, setKeyword] = useState("")
const [expanded, setExpanded] = useState<Set<number>>(new Set())
/** 仅首次有数据时应用「默认只展开第一级」;后续 data 刷新(建部门、保存等)不重置,保留用户展开状态 */
const defaultExpandAppliedRef = useRef(false)
const collectDefaultExpandedIds = useCallback(
(
@@ -44,11 +46,12 @@ export function DepartmentTree({ data, selectedDeptId, onSelect, onCreateChild }
[]
)
// 默认仅展开第一级(depth 0),更深层需用户手动展开
// 进入页面后首次拿到树数据时:默认仅展开第一级;之后不因 refetch 重置展开
useEffect(() => {
if (data.length > 0) {
setExpanded(new Set(collectDefaultExpandedIds(data, DEFAULT_EXPAND_MAX_DEPTH)))
}
if (data.length === 0) return
if (defaultExpandAppliedRef.current) return
setExpanded(new Set(collectDefaultExpandedIds(data, DEFAULT_EXPAND_MAX_DEPTH)))
defaultExpandAppliedRef.current = true
}, [data, collectDefaultExpandedIds])
const toggleExpand = useCallback((id: number, e: React.MouseEvent) => {
@@ -136,7 +139,10 @@ export function DepartmentTree({ data, selectedDeptId, onSelect, onCreateChild }
)}
</span>
<Building2 className="mr-1.5 h-4 w-4 shrink-0 text-muted-foreground" />
<span className="flex-1 truncate">{node.name}</span>
<span className="flex-1 truncate">
{node.name}
{isArchived ? ` ${t("bs:department.archivedTag")}` : ""}
</span>
<span className="mr-1 text-xs text-muted-foreground tabular-nums">{node.member_count}</span>
{/* Quick create child button — hidden for archived departments */}
{!isArchived && (
@@ -1,4 +1,3 @@
import { bsConfirm } from "@/components/bs-ui/alertDialog/useConfirm"
import { Badge } from "@/components/bs-ui/badge"
import { Button } from "@/components/bs-ui/button"
import { SearchInput } from "@/components/bs-ui/input"
@@ -26,17 +25,15 @@ import {
TooltipProvider,
TooltipTrigger,
} from "@/components/bs-ui/tooltip"
import {
getDepartmentMembersApi,
removeDepartmentMemberApi,
} from "@/controllers/API/department"
import { getDepartmentMembersApi } from "@/controllers/API/department"
import { disableUserApi } from "@/controllers/API/user"
import { captureAndAlertRequestErrorHoc } from "@/controllers/request"
import { userContext } from "@/contexts/userContext"
import UserPwdModal from "@/pages/LoginPage/UserPwdModal"
import { isSyncedSource } from "@/pages/DepartmentPage/constants/syncReadonly"
import { DepartmentMember } from "@/types/api/department"
import { useCallback, useContext, useEffect, useRef, useState } from "react"
import { buildMemberDisplayNameMap } from "@/utils/userDisplayName"
import { useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"
import { useTranslation } from "react-i18next"
import { CreateLocalMemberDialog } from "./CreateLocalMemberDialog"
import { OrganizationMemberEditDialog } from "./OrganizationMemberEditDialog"
@@ -45,9 +42,16 @@ interface MemberTableProps {
deptId: string
deptName: string
onChanged: () => void
/** 父级在部门设置等变更后递增,用于在不切换部门时强制重新拉取成员(如部门管理员 FGA 更新后刷新「角色」列) */
membersRefreshSignal?: number
}
export function MemberTable({ deptId, deptName, onChanged }: MemberTableProps) {
export function MemberTable({
deptId,
deptName,
onChanged,
membersRefreshSignal = 0,
}: MemberTableProps) {
const { t } = useTranslation()
const { user } = useContext(userContext)
const userPwdModalRef = useRef<{ open: (userId: string | number) => void } | null>(null)
@@ -61,6 +65,11 @@ export function MemberTable({ deptId, deptName, onChanged }: MemberTableProps) {
const pageRef = useRef(page)
pageRef.current = page
const memberDisplayNames = useMemo(
() => buildMemberDisplayNameMap(members),
[members]
)
const loadMembers = useCallback(() => {
const params: Record<string, string | number> = {
page: pageRef.current,
@@ -86,6 +95,11 @@ export function MemberTable({ deptId, deptName, onChanged }: MemberTableProps) {
loadMembers()
}, [deptId, keyword, isPrimaryFilter])
useEffect(() => {
if (membersRefreshSignal === 0) return
loadMembers()
}, [membersRefreshSignal, loadMembers])
const handlePageChange = useCallback(
(p: number) => {
setPage(p)
@@ -116,31 +130,6 @@ export function MemberTable({ deptId, deptName, onChanged }: MemberTableProps) {
[loadMembers, onChanged, t]
)
const handleRemove = useCallback(
(m: DepartmentMember) => {
bsConfirm({
title: t("bs:department.removeMember"),
desc: t("bs:department.confirmRemoveMember"),
onOk: (next) => {
captureAndAlertRequestErrorHoc(
removeDepartmentMemberApi(deptId, m.user_id)
).then((res) => {
if (res !== null) {
toast({
title: t("bs:department.removeMember"),
variant: "success",
})
loadMembers()
onChanged()
}
next()
})
},
})
},
[deptId, loadMembers, onChanged, t]
)
const handleAdded = useCallback(() => {
setAddOpen(false)
loadMembers()
@@ -198,7 +187,7 @@ export function MemberTable({ deptId, deptName, onChanged }: MemberTableProps) {
<TableHead>{t("bs:department.roles")}</TableHead>
<TableHead>{t("bs:department.updateTime")}</TableHead>
<TableHead>{t("bs:department.enabled")}</TableHead>
<TableHead className="min-w-[220px] text-right">{t("operations")}</TableHead>
<TableHead className="min-w-[140px] text-right">{t("operations")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -211,7 +200,9 @@ export function MemberTable({ deptId, deptName, onChanged }: MemberTableProps) {
) : (
members.map((m) => (
<TableRow key={m.user_id}>
<TableCell>{m.user_name}</TableCell>
<TableCell title={memberDisplayNames.get(m.user_id) ?? m.user_name}>
{memberDisplayNames.get(m.user_id) ?? m.user_name}
</TableCell>
<TableCell>
<Badge variant={m.is_primary === 1 ? "default" : "outline"}>
{m.is_primary === 1
@@ -237,7 +228,12 @@ export function MemberTable({ deptId, deptName, onChanged }: MemberTableProps) {
</TableCell>
<TableCell className="max-w-[150px]">
{(() => {
const text = m.roles.map((r) => r.role_name).join(", ") || "-"
const roleNames: string[] = []
if (m.is_department_admin) {
roleNames.push(t("bs:department.deptAdminRoleName"))
}
roleNames.push(...m.roles.map((r) => r.role_name))
const text = roleNames.join(", ") || "-"
return (
<Tooltip>
<TooltipTrigger asChild>
@@ -266,7 +262,6 @@ export function MemberTable({ deptId, deptName, onChanged }: MemberTableProps) {
{(() => {
const isSuperAdmin = m.roles.some((role) => role.id === 1)
const canResetPwd = user.role === "admin"
const canRemove = !isSuperAdmin && user.user_id !== m.user_id
return (
<div className="flex flex-wrap items-center justify-end gap-x-1">
{isSuperAdmin ? (
@@ -284,15 +279,6 @@ export function MemberTable({ deptId, deptName, onChanged }: MemberTableProps) {
{t("edit")}
</Button>
)}
<Button
variant="link"
size="sm"
className="px-1 text-destructive"
disabled={!canRemove}
onClick={() => handleRemove(m)}
>
{t("bs:department.removeMember")}
</Button>
{canResetPwd && (
<Button
variant="link"
@@ -332,17 +332,26 @@ export function OrganizationMemberEditDialog({
<div>
<Label>{t("bs:department.userGroups")}</Label>
<div className="max-h-40 space-y-2 overflow-y-auto rounded border p-2">
{form.manageable_groups.map((g) => (
<label key={g.id} className="flex cursor-pointer items-center gap-2 text-sm">
<Checkbox
checked={groupSel.has(g.id)}
onCheckedChange={(c) =>
setGroupSel((prev) => toggleSet(prev, g.id, Boolean(c)))
}
/>
<span>{g.group_name}</span>
</label>
))}
{form.manageable_groups.length === 0 ? (
<p className="text-sm text-muted-foreground">
{t("bs:department.memberEditUserGroupsEmpty")}
</p>
) : (
form.manageable_groups.map((g) => (
<label
key={g.id}
className="flex cursor-pointer items-center gap-2 text-sm"
>
<Checkbox
checked={groupSel.has(g.id)}
onCheckedChange={(c) =>
setGroupSel((prev) => toggleSet(prev, g.id, Boolean(c)))
}
/>
<span>{g.group_name}</span>
</label>
))
)}
</div>
</div>
{form.primary_department && (
@@ -16,6 +16,7 @@ export default function DepartmentPage() {
const [selectedDept, setSelectedDept] = useState<DepartmentTreeNode | null>(null)
const [createOpen, setCreateOpen] = useState(false)
const [createParentId, setCreateParentId] = useState<number | null>(null)
const [membersRefreshSignal, setMembersRefreshSignal] = useState(0)
const loadTree = useCallback(() => {
captureAndAlertRequestErrorHoc(getDepartmentTreeApi()).then((res) => {
@@ -102,7 +103,10 @@ export default function DepartmentPage() {
<Tabs defaultValue="members" className="w-full">
<div className="mb-4 flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold">{selectedDept.name}</h2>
<h2 className="text-lg font-semibold">
{selectedDept.name}
{selectedDept.status === "archived" ? ` ${t("bs:department.archivedTag")}` : ""}
</h2>
<span className="text-sm text-muted-foreground">
{t("bs:department.memberCount")}: {selectedDept.member_count}
</span>
@@ -117,6 +121,7 @@ export default function DepartmentPage() {
deptId={selectedDept.dept_id}
deptName={selectedDept.name}
onChanged={handleTreeChange}
membersRefreshSignal={membersRefreshSignal}
/>
</TabsContent>
<TabsContent value="settings">
@@ -23,7 +23,8 @@ export const LoginPage = () => {
const { appConfig } = useContext(locationContext)
const isLoading = false
const mailRef = useRef(null)
const personIdRef = useRef(null)
const userNameRef = useRef(null)
const pwdRef = useRef(null)
const agenPwdRef = useRef(null)
@@ -47,8 +48,8 @@ export const LoginPage = () => {
const [isLDAP, setIsLDAP] = useState(false)
const handleLogin = async () => {
const error = []
const [mail, pwd] = [mailRef.current.value, pwdRef.current.value]
if (!mail) error.push(t('login.pleaseEnterAccount'))
const [personId, pwd] = [personIdRef.current.value, pwdRef.current.value]
if (!personId) error.push(t('login.pleaseEnterPersonId'))
if (!pwd) error.push(t('login.pleaseEnterPassword'))
if (captchaData.user_capthca && !captchaRef.current.value) error.push(t('login.pleaseEnterCaptcha'))
if (error.length) return message({
@@ -64,8 +65,8 @@ export const LoginPage = () => {
const encryptPwd = isLDAP ? await handleLdapEncrypt(pwd) : await handleEncrypt(pwd)
captureAndAlertRequestErrorHoc(
(isLDAP
? ldapLoginApi(mail, encryptPwd)
: loginApi(mail, encryptPwd, captchaData.captcha_key, captchaRef.current?.value)
? ldapLoginApi(personId, encryptPwd)
: loginApi(personId, encryptPwd, captchaData.captcha_key, captchaRef.current?.value)
).then((res: any) => {
// Multi-tenant: check if tenant selection is required
if (res.requires_tenant_selection) {
@@ -90,7 +91,7 @@ export const LoginPage = () => {
}
}), (error) => {
if (error.indexOf('过期') !== -1) { // 有时间改为 code 判断
localStorage.setItem('account', mail)
localStorage.setItem('account', personId)
navigate('/reset', { state: { noback: true } })
return true // Skip the default error toast; resetPwd page shows its own
}
@@ -101,12 +102,20 @@ export const LoginPage = () => {
const handleRegister = async () => {
const error = []
const [mail, pwd, apwd] = [mailRef.current.value, pwdRef.current.value, agenPwdRef.current.value]
if (!mail) {
error.push(t('login.pleaseEnterAccount'))
const [personId, userName, pwd, apwd] = [
personIdRef.current.value,
userNameRef.current?.value,
pwdRef.current.value,
agenPwdRef.current.value
]
if (!personId) {
error.push(t('login.pleaseEnterPersonId'))
}
if (mail.length < 3) {
error.push(t('login.accountTooShort'))
if (!userName) {
error.push(t('login.pleaseEnterUserName'))
}
if (personId.length < 2) {
error.push(t('login.personIdTooShort'))
}
if (!/.{8,}/.test(pwd)) {
error.push(t('login.passwordTooShort'))
@@ -128,7 +137,7 @@ export const LoginPage = () => {
})
}
const encryptPwd = await handleEncrypt(pwd)
captureAndAlertRequestErrorHoc(registerApi(mail, encryptPwd, captchaData.captcha_key, captchaRef.current?.value).then(res => {
captureAndAlertRequestErrorHoc(registerApi(userName, personId, encryptPwd, captchaData.captcha_key, captchaRef.current?.value).then(res => {
// setSuccessData({ title: t('login.registrationSuccess') })
message({
title: `${t('prompt')}`,
@@ -161,14 +170,24 @@ export const LoginPage = () => {
<Input
id="email"
className='h-[48px] dark:bg-login-input'
ref={mailRef}
ref={personIdRef}
placeholder={t('login.account')}
type="email"
type="text"
autoCapitalize="none"
autoComplete="email"
autoComplete="username"
autoCorrect="off"
/>
</div>
{
!showLogin && <div className="grid">
<Input
id="userName"
className='h-[48px] dark:bg-login-input'
ref={userNameRef}
placeholder={t('login.userName')}
/>
</div>
}
<div className="grid">
<Input
id="pwd"
@@ -176,15 +195,18 @@ export const LoginPage = () => {
ref={pwdRef}
placeholder={t('login.password')}
type="password"
onKeyDown={e => e.key === 'Enter' && showLogin && handleLogin()} />
onKeyDown={e => e.key === 'Enter' && showLogin && handleLogin()}
/>
</div>
{
!showLogin && <div className="grid">
<Input id="pwd"
<Input
id="pwd"
className='h-[48px] dark:bg-login-input'
ref={agenPwdRef}
placeholder={t('login.confirmPassword')}
type="password" />
type="password"
/>
</div>
}
{
@@ -19,6 +19,7 @@ export default function Departments() {
const [selectedDept, setSelectedDept] = useState<DepartmentTreeNode | null>(null)
const [createOpen, setCreateOpen] = useState(false)
const [createParentId, setCreateParentId] = useState<number | null>(null)
const [membersRefreshSignal, setMembersRefreshSignal] = useState(0)
const loadTree = useCallback(() => {
captureAndAlertRequestErrorHoc(getDepartmentTreeApi()).then((res) => {
@@ -65,6 +66,7 @@ export default function Departments() {
const handleTreeChange = useCallback(() => {
loadTree()
setMembersRefreshSignal((n) => n + 1)
}, [loadTree])
useEffect(() => {
@@ -116,6 +118,7 @@ export default function Departments() {
deptId={selectedDept.dept_id}
deptName={selectedDept.name}
onChanged={handleTreeChange}
membersRefreshSignal={membersRefreshSignal}
/>
</TabsContent>
<TabsContent value="settings">
@@ -48,7 +48,6 @@ const ADMIN_CHILD_MENUS = [
"knowledge",
"build",
"evaluation",
"system_config",
"mark_task",
] as const
const DEFAULT_ENABLED_MENU_IDS = [
@@ -99,7 +98,6 @@ export default function Roles() {
{ id: "knowledge", label: t("menu.knowledge") },
{ id: "build", label: t("menu.skills") },
{ id: "evaluation", label: t("menu.evaluation") },
{ id: "system_config", label: t("system.systemConfiguration") },
{ id: "mark_task", label: t("menu.annotation") },
],
[t]
@@ -184,7 +182,7 @@ export default function Roles() {
setIsMenuLoading(false)
return
}
let ids = [...menuRes]
let ids = [...menuRes].filter((id) => id !== "system_config")
if (ids.some((id) => (ADMIN_CHILD_MENUS as readonly string[]).includes(id)) && !ids.includes(ADMIN_PARENT_ID)) {
ids = [...ids, ADMIN_PARENT_ID]
}
@@ -257,7 +255,7 @@ export default function Roles() {
message({ variant: "success", description: t("saved") })
setEditOpen(false)
loadRoles()
await loadRoles()
} finally {
setIsSaving(false)
}
@@ -328,7 +326,10 @@ export default function Roles() {
}
const scopeLabel = (el: ROLE) => {
if (el.role_type === "global" || !el.department_id) return t("system.scopeGlobal")
// 作用域以 department_id 为准;全路径优先用后端按 path 解析的字段(不受部门树裁剪影响)
if (!el.department_id) return t("system.scopeGlobal")
const fromApi = (el.department_scope_path || "").trim()
if (fromApi) return fromApi
return getDepartmentDisplayPath(deptTree, el.department_id) || el.department_name || "-"
}
@@ -416,7 +417,7 @@ export default function Roles() {
roles.map((el) => (
<TableRow key={el.id}>
<TableCell className="font-medium">{el.role_name}</TableCell>
<TableCell className="max-w-[280px] truncate" title={scopeLabel(el)}>
<TableCell className="min-w-[200px] max-w-md whitespace-normal break-words text-left">
{scopeLabel(el)}
</TableCell>
<TableCell>{el.user_count ?? "-"}</TableCell>
@@ -424,22 +425,27 @@ export default function Roles() {
<TableCell>{fmtTime(el.create_time)}</TableCell>
<TableCell>{fmtTime(el.update_time)}</TableCell>
<TableCell className="text-right">
<Button
variant="link"
disabled={Boolean(el.is_readonly)}
className="px-0 pl-4"
onClick={() => openEdit(el)}
>
{t("edit")}
</Button>
<Button
variant="link"
disabled={Boolean(el.is_readonly) || [1, 2].includes(el.id)}
className="px-0 pl-4 text-red-500"
onClick={() => onDelete(el)}
>
{t("delete")}
</Button>
{el.is_readonly ? (
<span className="text-sm text-muted-foreground">&mdash;</span>
) : (
<>
<Button
variant="link"
className="px-0 pl-4"
onClick={() => openEdit(el)}
>
{t("edit")}
</Button>
<Button
variant="link"
disabled={[1, 2].includes(el.id)}
className="px-0 pl-4 text-red-500"
onClick={() => onDelete(el)}
>
{t("delete")}
</Button>
</>
)}
</TableCell>
</TableRow>
))
@@ -18,26 +18,31 @@ export default function index() {
const { user } = useContext(userContext)
const { t } = useTranslation()
const isSuperAdmin = user?.role === "admin"
const isDeptAdmin = !!user?.is_department_admin
const showOrgTab = isSuperAdmin || isDeptAdmin
const canAccessSystemConfig = isSuperAdmin || isDeptAdmin
const showUserGroupTab =
user?.role === "admin" || !!user?.can_manage_user_groups
/** 仅非部门管理员、非超管展示旧版扁平用户表(依赖用户组维度) */
const showLegacyUserTab = !isSuperAdmin && !isDeptAdmin
const defaultTab =
user?.role === "admin"
? "organization"
: showUserGroupTab
? "userGroup"
: "user"
const defaultTab = showOrgTab
? "organization"
: showUserGroupTab
? "userGroup"
: "user"
return (
<div className="h-full w-full px-2 pt-4">
<Tabs defaultValue={defaultTab} className="w-full">
<TabsList className="">
{user.role === "admin" && (
{showOrgTab && (
<TabsTrigger value="organization">
{t("system.orgAndMembers")}
</TabsTrigger>
)}
{user.role !== "admin" && (
{showLegacyUserTab && (
<TabsTrigger value="user" className="roundedrounded-xl">
{t("system.userManagement")}
</TabsTrigger>
@@ -46,19 +51,19 @@ export default function index() {
<TabsTrigger value="userGroup">{t("system.userGroupsM")}</TabsTrigger>
)}
<TabsTrigger value="role">{t("system.roleAndPermissions")}</TabsTrigger>
{user.role === "admin" && (
{canAccessSystemConfig && (
<TabsTrigger value="system">{t("system.systemConfiguration")}</TabsTrigger>
)}
{user.role === "admin" && (
{canAccessSystemConfig && (
<TabsTrigger value="theme">{t("system.themeColor")}</TabsTrigger>
)}
</TabsList>
{user.role === "admin" && (
{showOrgTab && (
<TabsContent value="organization">
<OrganizationAndMembers />
</TabsContent>
)}
{user.role !== "admin" && (
{showLegacyUserTab && (
<TabsContent value="user">
<Users />
</TabsContent>
@@ -71,12 +76,12 @@ export default function index() {
<TabsContent value="role">
<RolesAndPermissions />
</TabsContent>
{user.role === "admin" && (
{canAccessSystemConfig && (
<TabsContent value="system">
<Config />
</TabsContent>
)}
{user.role === "admin" && (
{canAccessSystemConfig && (
<TabsContent value="theme">
<Theme />
</TabsContent>
+11 -1
View File
@@ -69,6 +69,8 @@ const privateRouter = [
element: <MainLayout />,
errorElement: <RouteErrorBoundary />,
children: [
// 开发环境登录后曾跳转 /admin,但业务路由无该 path,会落入 * → 404;统一进管理端后再由 userContext 纠偏
{ path: "admin", element: <Navigate to="/label" replace /> },
// { path: "", element: <SkillChatPage />, },
{ path: "filelib", element: <KnowledgePage />, permission: 'knowledge', },
{ path: "filelib/:id", element: <FilesPage />, permission: 'knowledge', },
@@ -148,6 +150,14 @@ const privateRouter = [
{ path: "*", element: <Navigate to="/404" replace /> }
]
/** 与角色菜单 third_id 对齐:/sys 路由使用 permission `sys`,角色侧存为 system_config */
function hasRoutePermission(permissions: string[], key: string) {
if (key === "sys") {
return permissions.includes("sys") || permissions.includes("system_config")
}
return permissions.includes(key)
}
export const getPrivateRouter = (permissions) => {
const filterMenuItem = (_privateRouter) => {
const result = _privateRouter.reduce((res, cur) => {
@@ -157,7 +167,7 @@ export const getPrivateRouter = (permissions) => {
}
const { permission, ...other } = cur
if (permission && !permissions.includes(permission)) {
if (permission && !hasRoutePermission(permissions, permission)) {
return res
}
@@ -32,6 +32,8 @@ export interface DepartmentDetail {
export interface DepartmentMember {
user_id: number
user_name: string
/** 本地人员 IDexternal_id),用于重名展示;非展示字段 */
person_id?: string | null
department_id: number
is_primary: number
source: string
@@ -40,6 +42,8 @@ export interface DepartmentMember {
enabled: boolean
user_groups: { id: number; group_name: string }[]
roles: { id: number; role_name: string }[]
/** 当前部门 OpenFGA admin;用于在角色列最前展示「部门管理员」 */
is_department_admin?: boolean
}
export interface DepartmentCreateForm {
@@ -1,5 +1,6 @@
export type User = {
user_name: string;
external_id?: string | null;
email: string | null;
phone_number: string | null;
dept_id: number | null;
@@ -26,6 +27,8 @@ export type ROLE = {
role_type?: string
department_id?: number | null
department_name?: string | null
/** 组织根 → 作用域部门 全路径(后端按 Department.path 解析) */
department_scope_path?: string | null
quota_config?: Record<string, any> | null
user_count?: number
creator_name?: string | null
@@ -0,0 +1,47 @@
/** 重名时在展示中附加人员 ID;不修改数据库中的 user_name。 */
export type UserDisplayPeer = {
user_name: string
user_id?: number
person_id?: string | null
}
export function formatUserDisplayName(
u: UserDisplayPeer,
peers?: UserDisplayPeer[]
): string {
const name = (u.user_name || "").trim() || "-"
if (!peers || peers.length < 2) return name
const same = peers.filter((p) => (p.user_name || "").trim() === name)
if (same.length < 2) return name
const pid =
(u.person_id && String(u.person_id).trim()) ||
(u.user_id != null ? String(u.user_id) : "")
if (!pid) return name
return `${name}${pid}`
}
export function buildMemberDisplayNameMap(
members: { user_id: number; user_name: string; person_id?: string | null }[]
): Map<number, string> {
const peers: UserDisplayPeer[] = members.map((m) => ({
user_name: m.user_name,
user_id: m.user_id,
person_id: m.person_id ?? undefined,
}))
const map = new Map<number, string>()
for (const m of members) {
map.set(
m.user_id,
formatUserDisplayName(
{
user_name: m.user_name,
user_id: m.user_id,
person_id: m.person_id ?? undefined,
},
peers
)
)
}
return map
}