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:
@@ -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'
|
||||
|
||||
@@ -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)
|
||||
@@ -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(
|
||||
|
||||
@@ -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,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:{}'
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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 department’s 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 作为管理端父级;旧数据可能仍为 backend(WebMenuResource 遗留)
|
||||
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
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
+4
-4
@@ -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])
|
||||
|
||||
|
||||
+19
-13
@@ -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"
|
||||
|
||||
+20
-11
@@ -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">—</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>
|
||||
|
||||
@@ -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
|
||||
/** 本地人员 ID(external_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
|
||||
}
|
||||
Reference in New Issue
Block a user