diff --git a/backend/app/api/v1/projects.py b/backend/app/api/v1/projects.py index 15ff222..c60d05a 100644 --- a/backend/app/api/v1/projects.py +++ b/backend/app/api/v1/projects.py @@ -13,7 +13,7 @@ import secrets from app.core.database import get_db from app.core.deps import get_current_user from app.models.user import User -from app.models.project import Project, ProjectMember +from app.models.project import Project, ProjectMember, ProjectMemberRole from app.models.git_repo import ProjectGitRepo from app.models.share import ShareLink from app.schemas.project import ( @@ -30,6 +30,7 @@ from app.services.storage import storage_service from app.services.log_service import log_service from app.services.git_service import git_service from app.services.notification_service import notification_service +from app.services.project_service import normalize_project_role, require_project_roles from app.core.enums import OperationType, ResourceType router = APIRouter() @@ -206,7 +207,7 @@ async def get_shared_projects( project_dict = ProjectResponse.from_orm(project).dict() project_dict['owner_name'] = owner.username project_dict['owner_nickname'] = owner.nickname - project_dict['user_role'] = member.role # 添加用户角色 + project_dict['user_role'] = normalize_project_role(member.role) doc_count, last_activity_at = get_project_document_stats(project.storage_key) project_dict['doc_count'] = doc_count project_dict['last_activity_at'] = last_activity_at @@ -581,7 +582,7 @@ async def get_project_members( "id": member.id, "project_id": member.project_id, "user_id": member.user_id, - "role": member.role, + "role": normalize_project_role(member.role), "joined_at": member.joined_at.isoformat() if member.joined_at else None, "username": user.username, "nickname": user.nickname, @@ -599,25 +600,13 @@ async def add_project_member( db: AsyncSession = Depends(get_db) ): """添加项目成员""" - # 查询项目 - result = await db.execute(select(Project).where(Project.id == project_id)) - project = result.scalar_one_or_none() - - if not project: - raise HTTPException(status_code=404, detail="项目不存在") - - # 只有项目所有者和管理员可以添加成员 - if project.owner_id != current_user.id: - member_result = await db.execute( - select(ProjectMember).where( - ProjectMember.project_id == project_id, - ProjectMember.user_id == current_user.id, - ProjectMember.role == "admin" - ) - ) - member = member_result.scalar_one_or_none() - if not member: - raise HTTPException(status_code=403, detail="无权添加成员") + project, _ = await require_project_roles( + db, + project_id, + current_user, + allowed_roles=[ProjectMemberRole.ADMIN.value], + forbidden_detail="无权添加成员", + ) # 检查用户是否已是成员 existing_result = await db.execute( @@ -678,24 +667,13 @@ async def update_project_member_role( db: AsyncSession = Depends(get_db) ): """修改项目成员角色""" - # 查询项目 - result = await db.execute(select(Project).where(Project.id == project_id)) - project = result.scalar_one_or_none() - if not project: - raise HTTPException(status_code=404, detail="项目不存在") - - # 只有项目所有者和管理员可以修改成员角色 - if project.owner_id != current_user.id: - member_result = await db.execute( - select(ProjectMember).where( - ProjectMember.project_id == project_id, - ProjectMember.user_id == current_user.id, - ProjectMember.role == "admin" - ) - ) - member = member_result.scalar_one_or_none() - if not member: - raise HTTPException(status_code=403, detail="无权修改成员角色") + project, _ = await require_project_roles( + db, + project_id, + current_user, + allowed_roles=[ProjectMemberRole.ADMIN.value], + forbidden_detail="无权修改成员角色", + ) # 不能修改项目所有者的角色 if project.owner_id == user_id: @@ -741,25 +719,13 @@ async def remove_project_member( db: AsyncSession = Depends(get_db) ): """删除项目成员""" - # 查询项目 - result = await db.execute(select(Project).where(Project.id == project_id)) - project = result.scalar_one_or_none() - - if not project: - raise HTTPException(status_code=404, detail="项目不存在") - - # 只有项目所有者和管理员可以删除成员 - if project.owner_id != current_user.id: - member_result = await db.execute( - select(ProjectMember).where( - ProjectMember.project_id == project_id, - ProjectMember.user_id == current_user.id, - ProjectMember.role == "admin" - ) - ) - member = member_result.scalar_one_or_none() - if not member: - raise HTTPException(status_code=403, detail="无权删除成员") + project, _ = await require_project_roles( + db, + project_id, + current_user, + allowed_roles=[ProjectMemberRole.ADMIN.value], + forbidden_detail="无权删除成员", + ) # 不能删除项目所有者 if user_id == project.owner_id: @@ -822,24 +788,13 @@ async def git_pull( db: AsyncSession = Depends(get_db) ): """执行 Git Pull(sub_path 非空时仅同步指定目录)""" - # 查询项目 - result = await db.execute(select(Project).where(Project.id == project_id)) - project = result.scalar_one_or_none() - - if not project: - raise HTTPException(status_code=404, detail="项目不存在") - - # 权限检查:需要是所有者或管理员/编辑者 - if project.owner_id != current_user.id: - member_result = await db.execute( - select(ProjectMember).where( - ProjectMember.project_id == project_id, - ProjectMember.user_id == current_user.id, - ProjectMember.role.in_(['admin', 'editor']) - ) - ) - if not member_result.scalar_one_or_none(): - raise HTTPException(status_code=403, detail="无权执行Git操作") + project, _ = await require_project_roles( + db, + project_id, + current_user, + allowed_roles=[ProjectMemberRole.ADMIN.value], + forbidden_detail="无权执行Git操作", + ) # 获取Git仓库配置 query = select(ProjectGitRepo).where(ProjectGitRepo.project_id == project_id) @@ -925,24 +880,13 @@ async def git_push( db: AsyncSession = Depends(get_db) ): """执行 Git Push(sub_path 非空时仅推送指定目录的变更)""" - # 查询项目 - result = await db.execute(select(Project).where(Project.id == project_id)) - project = result.scalar_one_or_none() - - if not project: - raise HTTPException(status_code=404, detail="项目不存在") - - # 权限检查:需要是所有者或管理员/编辑者 - if project.owner_id != current_user.id: - member_result = await db.execute( - select(ProjectMember).where( - ProjectMember.project_id == project_id, - ProjectMember.user_id == current_user.id, - ProjectMember.role.in_(['admin', 'editor']) - ) - ) - if not member_result.scalar_one_or_none(): - raise HTTPException(status_code=403, detail="无权执行Git操作") + project, _ = await require_project_roles( + db, + project_id, + current_user, + allowed_roles=[ProjectMemberRole.ADMIN.value], + forbidden_detail="无权执行Git操作", + ) # 获取Git仓库配置 query = select(ProjectGitRepo).where(ProjectGitRepo.project_id == project_id) @@ -1036,22 +980,13 @@ async def list_git_directories( db: AsyncSession = Depends(get_db) ): """列出指定 Git 仓库远端目录结构(用于选择同步目录)""" - result = await db.execute(select(Project).where(Project.id == project_id)) - project = result.scalar_one_or_none() - if not project: - raise HTTPException(status_code=404, detail="项目不存在") - - # 权限检查:需要是所有者或管理员/编辑者 - if project.owner_id != current_user.id: - member_result = await db.execute( - select(ProjectMember).where( - ProjectMember.project_id == project_id, - ProjectMember.user_id == current_user.id, - ProjectMember.role.in_(["admin", "editor"]), - ) - ) - if not member_result.scalar_one_or_none(): - raise HTTPException(status_code=403, detail="无权操作Git仓库") + project, _ = await require_project_roles( + db, + project_id, + current_user, + allowed_roles=[ProjectMemberRole.ADMIN.value], + forbidden_detail="无权操作Git仓库", + ) query = select(ProjectGitRepo).where(ProjectGitRepo.project_id == project_id) if repo_id: diff --git a/backend/app/services/project_service.py b/backend/app/services/project_service.py index fe85ca4..5d61716 100644 --- a/backend/app/services/project_service.py +++ b/backend/app/services/project_service.py @@ -16,6 +16,15 @@ OWNER_ROLE = "owner" PUBLIC_ROLE = "public" +def normalize_project_role(role: Optional[str]) -> Optional[str]: + """兼容历史数据中的角色大小写差异。""" + if role is None: + return None + if isinstance(role, ProjectMemberRole): + return role.value + return str(role).strip().lower() + + async def get_project_or_404(db: AsyncSession, project_id: int) -> Project: """获取项目,不存在时抛出 404。""" result = await db.execute(select(Project).where(Project.id == project_id)) @@ -53,7 +62,7 @@ async def get_project_role( return OWNER_ROLE member = await get_project_member(db, project.id, current_user.id) - return member.role if member else None + return normalize_project_role(member.role) if member else None async def require_project_read_access( @@ -101,7 +110,12 @@ async def require_project_roles( forbidden_detail=forbidden_detail, ) - if role in {OWNER_ROLE, *allowed_roles}: + normalized_allowed_roles = { + normalize_project_role(allowed_role) + for allowed_role in allowed_roles + } + + if role in {OWNER_ROLE, *normalized_allowed_roles}: return project, role raise HTTPException(status_code=403, detail=forbidden_detail) diff --git a/backend/tests/test_project_permissions.py b/backend/tests/test_project_permissions.py new file mode 100644 index 0000000..e5b4508 --- /dev/null +++ b/backend/tests/test_project_permissions.py @@ -0,0 +1,86 @@ +import unittest +from types import SimpleNamespace + +from fastapi import HTTPException + +from app.services.project_service import ( + normalize_project_role, + require_project_roles, + require_project_write_access, +) + + +class _ScalarResult: + def __init__(self, value): + self.value = value + + def scalar_one_or_none(self): + return self.value + + +class _RecordingDB: + def __init__(self, values): + self.values = iter(values) + + async def execute(self, statement): + return _ScalarResult(next(self.values)) + + +class ProjectPermissionsTest(unittest.IsolatedAsyncioTestCase): + def setUp(self): + self.project = SimpleNamespace(id=25, owner_id=1) + self.current_user = SimpleNamespace(id=2) + + def test_normalize_project_role_handles_legacy_case(self): + self.assertEqual(normalize_project_role(" ADMIN "), "admin") + self.assertEqual(normalize_project_role("Editor"), "editor") + self.assertIsNone(normalize_project_role(None)) + + async def test_write_access_accepts_legacy_uppercase_admin(self): + db = _RecordingDB([ + self.project, + SimpleNamespace(role="ADMIN"), + ]) + + project, role = await require_project_write_access(db, 25, self.current_user) + + self.assertEqual(project, self.project) + self.assertEqual(role, "admin") + + async def test_write_access_accepts_legacy_uppercase_editor(self): + db = _RecordingDB([ + self.project, + SimpleNamespace(role="EDITOR"), + ]) + + project, role = await require_project_write_access(db, 25, self.current_user) + + self.assertEqual(project, self.project) + self.assertEqual(role, "editor") + + async def test_write_access_rejects_viewer(self): + db = _RecordingDB([ + self.project, + SimpleNamespace(role="viewer"), + ]) + + with self.assertRaises(HTTPException) as context: + await require_project_write_access(db, 25, self.current_user) + + self.assertEqual(context.exception.status_code, 403) + + async def test_admin_only_permission_rejects_editor(self): + db = _RecordingDB([ + self.project, + SimpleNamespace(role="EDITOR"), + ]) + + with self.assertRaises(HTTPException) as context: + await require_project_roles( + db, + 25, + self.current_user, + allowed_roles=["admin"], + ) + + self.assertEqual(context.exception.status_code, 403) diff --git a/frontend/src/components/ModeSwitch/ModeSwitch.css b/frontend/src/components/ModeSwitch/ModeSwitch.css index be1c770..2613995 100644 --- a/frontend/src/components/ModeSwitch/ModeSwitch.css +++ b/frontend/src/components/ModeSwitch/ModeSwitch.css @@ -57,6 +57,27 @@ opacity: 1; } +.mode-switch-disabled { + opacity: 0.55; + cursor: not-allowed; +} + +.mode-switch-disabled .mode-switch-option, +.mode-switch-option:disabled { + cursor: not-allowed; +} + +.mode-switch-disabled .mode-switch-option:hover { + color: #4b5f7f; + opacity: 0.95; +} + +.mode-switch-disabled .mode-switch-option.active, +.mode-switch-disabled .mode-switch-option.active:hover { + color: #1f3558; + opacity: 1; +} + .mode-switch-small { padding: 2px; } @@ -87,3 +108,12 @@ body.dark .mode-switch-option:hover, body.dark .mode-switch-option.active { color: #e5eeff; } + +body.dark .mode-switch-disabled .mode-switch-option:hover { + color: #9bb2d8; +} + +body.dark .mode-switch-disabled .mode-switch-option.active, +body.dark .mode-switch-disabled .mode-switch-option.active:hover { + color: #e5eeff; +} diff --git a/frontend/src/components/ModeSwitch/ModeSwitch.jsx b/frontend/src/components/ModeSwitch/ModeSwitch.jsx index c23b4f6..fe843e0 100644 --- a/frontend/src/components/ModeSwitch/ModeSwitch.jsx +++ b/frontend/src/components/ModeSwitch/ModeSwitch.jsx @@ -8,6 +8,7 @@ function ModeSwitch({ options, ariaLabel = '模式切换', size = 'default', + disabled = false, }) { const finalOptions = options || [ { label: viewLabel, value: 'view' }, @@ -20,7 +21,7 @@ function ModeSwitch({ return (