feat: add user and role management pages with API integration

- Implemented user management functionality in UsersPage including user creation, editing, deletion, and role assignment.
- Added role management functionality in RolesPage with role creation, editing, deletion, and path rule management.
- Created users API for handling user-related operations.
- Created roles API for handling role-related operations.
- Integrated permissions handling in both user and role management.
- Enhanced UI with Ant Design components for better user experience.
This commit is contained in:
shiyu
2026-01-30 15:59:22 +08:00
parent 4a2e01196d
commit e6ab01ef9d
33 changed files with 3462 additions and 10 deletions
+6
View File
@@ -16,6 +16,9 @@ from domain.virtual_fs import api as virtual_fs
from domain.virtual_fs.mapping import s3_api, webdav_api
from domain.virtual_fs.search import search_api
from domain.audit import api as audit
from domain.permission import api as permission
from domain.user import api as user
from domain.role import api as role
def include_routers(app: FastAPI):
@@ -38,3 +41,6 @@ def include_routers(app: FastAPI):
app.include_router(offline_downloads.router)
app.include_router(email.router)
app.include_router(audit.router)
app.include_router(permission.router)
app.include_router(user.router)
app.include_router(role.router)
+11
View File
@@ -140,6 +140,7 @@ class AuthService:
email=user.email,
full_name=user.full_name,
disabled=user.disabled,
is_admin=user.is_admin,
hashed_password=user.hashed_password,
)
return None
@@ -166,12 +167,14 @@ class AuthService:
if exists:
raise HTTPException(status_code=400, detail="用户名已存在")
hashed = cls.get_password_hash(payload.password)
# 第一个用户自动成为超级管理员
user = await UserAccount.create(
username=payload.username,
email=payload.email,
full_name=payload.full_name,
hashed_password=hashed,
disabled=False,
is_admin=True, # 第一个用户是超级管理员
)
return user
@@ -195,6 +198,13 @@ class AuthService:
detail="用户名或密码错误",
headers={"WWW-Authenticate": "Bearer"},
)
# 更新最后登录时间
db_user = await UserAccount.get_or_none(id=user.id)
if db_user:
db_user.last_login = _now()
await db_user.save(update_fields=["last_login"])
access_token_expires = timedelta(minutes=cls.access_token_expire_minutes)
access_token = await cls.create_access_token(
data={"sub": user.username}, expires_delta=access_token_expires
@@ -212,6 +222,7 @@ class AuthService:
"email": getattr(user, "email", None),
"full_name": getattr(user, "full_name", None),
"gravatar_url": gravatar_url,
"is_admin": getattr(user, "is_admin", False),
}
@classmethod
+1
View File
@@ -16,6 +16,7 @@ class User(BaseModel):
email: str | None = None
full_name: str | None = None
disabled: bool | None = None
is_admin: bool = False
class UserInDB(User):
+4
View File
@@ -0,0 +1,4 @@
from .service import PermissionService
from .matcher import PathMatcher
__all__ = ["PermissionService", "PathMatcher"]
+41
View File
@@ -0,0 +1,41 @@
from typing import Annotated
from fastapi import APIRouter, Depends
from domain.auth.service import get_current_active_user
from domain.auth.types import User
from .service import PermissionService
from .types import (
PathPermissionCheck,
PathPermissionResult,
UserPermissions,
PermissionInfo,
)
router = APIRouter(prefix="/api", tags=["permissions"])
@router.get("/permissions", response_model=list[PermissionInfo])
async def get_all_permissions(
current_user: Annotated[User, Depends(get_current_active_user)]
) -> list[PermissionInfo]:
"""获取所有权限定义"""
return await PermissionService.get_all_permissions()
@router.get("/me/permissions", response_model=UserPermissions)
async def get_my_permissions(
current_user: Annotated[User, Depends(get_current_active_user)]
) -> UserPermissions:
"""获取当前用户的有效权限"""
return await PermissionService.get_user_permissions(current_user.id)
@router.post("/me/check-path", response_model=PathPermissionResult)
async def check_path_permission(
data: PathPermissionCheck,
current_user: Annotated[User, Depends(get_current_active_user)],
) -> PathPermissionResult:
"""检查当前用户对某路径的权限"""
return await PermissionService.check_path_permission_detailed(
current_user.id, data.path, data.action
)
+158
View File
@@ -0,0 +1,158 @@
import re
import fnmatch
from functools import lru_cache
class PathMatcher:
"""路径匹配器,支持精确匹配、通配符匹配和正则匹配"""
@classmethod
def normalize_path(cls, path: str) -> str:
"""规范化路径"""
if not path:
return "/"
# 确保以 / 开头
if not path.startswith("/"):
path = "/" + path
# 移除末尾的 /(除了根路径)
if path != "/" and path.endswith("/"):
path = path.rstrip("/")
return path
@classmethod
def get_parent_path(cls, path: str) -> str | None:
"""获取父目录路径"""
path = cls.normalize_path(path)
if path == "/":
return None
parent = "/".join(path.rsplit("/", 1)[:-1])
return parent if parent else "/"
@classmethod
def match_pattern(cls, path: str, pattern: str, is_regex: bool = False) -> bool:
"""
匹配路径和模式
Args:
path: 要匹配的路径
pattern: 匹配模式
is_regex: 是否为正则表达式
Returns:
是否匹配
"""
path = cls.normalize_path(path)
pattern = cls.normalize_path(pattern)
if is_regex:
return cls._match_regex(path, pattern)
else:
return cls._match_glob(path, pattern)
@classmethod
def _match_regex(cls, path: str, pattern: str) -> bool:
"""正则表达式匹配"""
try:
# 限制正则表达式的复杂度,防止 ReDoS 攻击
if len(pattern) > 500:
return False
regex = re.compile(pattern)
return bool(regex.match(path))
except re.error:
return False
@classmethod
def _match_glob(cls, path: str, pattern: str) -> bool:
"""
通配符匹配
支持的语法:
- * : 匹配单层目录中的任意字符
- ** : 匹配任意层级目录
- ? : 匹配单个字符
"""
# 精确匹配
if pattern == path:
return True
# 处理 ** 通配符
if "**" in pattern:
return cls._match_double_star(path, pattern)
# 使用 fnmatch 进行标准通配符匹配
return fnmatch.fnmatch(path, pattern)
@classmethod
def _match_double_star(cls, path: str, pattern: str) -> bool:
"""处理 ** 通配符匹配"""
# 将 ** 替换为特殊标记
parts = pattern.split("**")
if len(parts) == 2:
prefix, suffix = parts
# 移除 prefix 末尾的 / 和 suffix 开头的 /
prefix = prefix.rstrip("/") if prefix else ""
suffix = suffix.lstrip("/") if suffix else ""
# 检查前缀匹配
if prefix and not path.startswith(prefix):
return False
# 如果没有后缀,只需要前缀匹配
if not suffix:
return True
# 检查后缀匹配
remaining = path[len(prefix):].lstrip("/") if prefix else path.lstrip("/")
# 后缀可以出现在任意位置
if "*" in suffix or "?" in suffix:
# 后缀包含通配符,逐层检查
path_parts = remaining.split("/")
suffix_parts = suffix.split("/")
# 简化处理:检查路径的最后几层是否与后缀匹配
if len(path_parts) >= len(suffix_parts):
tail = "/".join(path_parts[-len(suffix_parts):])
return fnmatch.fnmatch(tail, suffix)
return False
else:
# 后缀是精确字符串
return remaining.endswith(suffix) or ("/" + suffix) in remaining or remaining == suffix
# 多个 ** 的情况,使用简化匹配
regex_pattern = pattern.replace("**", ".*").replace("*", "[^/]*").replace("?", ".")
try:
return bool(re.match(f"^{regex_pattern}$", path))
except re.error:
return False
@classmethod
def get_pattern_specificity(cls, pattern: str, is_regex: bool = False) -> int:
"""
计算模式的具体程度(用于优先级排序)
返回值越大表示模式越具体
"""
pattern = cls.normalize_path(pattern)
if is_regex:
# 正则表达式具体程度较低
return len(pattern) // 2
# 精确路径最具体
if "*" not in pattern and "?" not in pattern:
return len(pattern) * 10
# 计算非通配符部分的长度
specificity = 0
parts = pattern.split("/")
for part in parts:
if part == "**":
specificity += 1
elif "*" in part or "?" in part:
specificity += 5
else:
specificity += 10
return specificity
+351
View File
@@ -0,0 +1,351 @@
from typing import List, Optional
from fastapi import HTTPException
from models.database import (
UserAccount,
Role,
UserRole,
Permission,
RolePermission,
PathRule,
)
from .matcher import PathMatcher
from .types import (
PathAction,
PathRuleInfo,
PathPermissionResult,
UserPermissions,
PermissionInfo,
PERMISSION_DEFINITIONS,
)
class PermissionService:
"""权限检查服务"""
# 权限检查结果缓存(简单的内存缓存)
_cache: dict[str, tuple[bool, float]] = {}
_cache_ttl = 300 # 5分钟缓存
@classmethod
async def check_path_permission(
cls, user_id: int, path: str, action: str
) -> bool:
"""
检查用户对路径的操作权限
Args:
user_id: 用户ID
path: 要检查的路径
action: 操作类型 (read/write/delete/share)
Returns:
是否有权限
"""
import time
# 检查缓存
cache_key = f"{user_id}:{path}:{action}"
if cache_key in cls._cache:
result, timestamp = cls._cache[cache_key]
if time.time() - timestamp < cls._cache_ttl:
return result
# 获取用户
user = await UserAccount.get_or_none(id=user_id)
if not user:
return False
# 超级管理员直接放行
if user.is_admin:
cls._cache[cache_key] = (True, time.time())
return True
# 获取用户所有角色
user_roles = await UserRole.filter(user_id=user_id).prefetch_related("role")
role_ids = [ur.role_id for ur in user_roles]
if not role_ids:
cls._cache[cache_key] = (False, time.time())
return False
# 获取所有角色的路径规则
path_rules = await PathRule.filter(role_id__in=role_ids).order_by("-priority")
# 规范化路径
normalized_path = PathMatcher.normalize_path(path)
# 按优先级和具体程度匹配
result = cls._match_path_rules(normalized_path, action, list(path_rules))
# 如果没有匹配到规则,检查父目录(继承)
if result is None:
parent_path = PathMatcher.get_parent_path(normalized_path)
if parent_path:
result = await cls.check_path_permission(user_id, parent_path, action)
else:
result = False # 默认拒绝
cls._cache[cache_key] = (result, time.time())
return result
@classmethod
def _match_path_rules(
cls, path: str, action: str, rules: List[PathRule]
) -> Optional[bool]:
"""
匹配路径规则
Returns:
True/False 表示明确的权限结果,None 表示没有匹配到规则
"""
# 按优先级和具体程度排序
sorted_rules = sorted(
rules,
key=lambda r: (
r.priority,
PathMatcher.get_pattern_specificity(r.path_pattern, r.is_regex),
),
reverse=True,
)
for rule in sorted_rules:
if PathMatcher.match_pattern(path, rule.path_pattern, rule.is_regex):
# 匹配到规则,检查具体操作权限
if action == PathAction.READ:
return rule.can_read
elif action == PathAction.WRITE:
return rule.can_write
elif action == PathAction.DELETE:
return rule.can_delete
elif action == PathAction.SHARE:
return rule.can_share
else:
return False
return None
@classmethod
async def check_system_permission(cls, user_id: int, permission_code: str) -> bool:
"""检查用户的系统/适配器权限"""
# 获取用户
user = await UserAccount.get_or_none(id=user_id)
if not user:
return False
# 超级管理员直接放行
if user.is_admin:
return True
# 获取用户所有角色
user_roles = await UserRole.filter(user_id=user_id)
role_ids = [ur.role_id for ur in user_roles]
if not role_ids:
return False
# 检查角色是否有该权限
permission = await Permission.get_or_none(code=permission_code)
if not permission:
return False
role_permission = await RolePermission.filter(
role_id__in=role_ids, permission_id=permission.id
).first()
return role_permission is not None
@classmethod
async def require_path_permission(
cls, user_id: int, path: str, action: str
) -> None:
"""要求用户具有路径权限,否则抛出 403"""
if not await cls.check_path_permission(user_id, path, action):
raise HTTPException(403, detail=f"没有权限执行此操作: {action}")
@classmethod
async def require_system_permission(
cls, user_id: int, permission_code: str
) -> None:
"""要求用户具有系统权限,否则抛出 403"""
if not await cls.check_system_permission(user_id, permission_code):
raise HTTPException(403, detail=f"没有权限: {permission_code}")
@classmethod
async def get_user_permissions(cls, user_id: int) -> UserPermissions:
"""获取用户的所有权限"""
user = await UserAccount.get_or_none(id=user_id)
if not user:
raise HTTPException(404, detail="用户不存在")
# 超级管理员拥有所有权限
if user.is_admin:
all_permissions = await Permission.all()
all_path_rules = await PathRule.all()
return UserPermissions(
user_id=user_id,
is_admin=True,
permissions=[p.code for p in all_permissions],
path_rules=[
PathRuleInfo(
id=r.id,
role_id=r.role_id,
path_pattern=r.path_pattern,
is_regex=r.is_regex,
can_read=r.can_read,
can_write=r.can_write,
can_delete=r.can_delete,
can_share=r.can_share,
priority=r.priority,
created_at=r.created_at,
)
for r in all_path_rules
],
)
# 获取用户角色
user_roles = await UserRole.filter(user_id=user_id)
role_ids = [ur.role_id for ur in user_roles]
# 获取权限
permissions = []
if role_ids:
role_permissions = await RolePermission.filter(
role_id__in=role_ids
).prefetch_related("permission")
permissions = list(set(rp.permission.code for rp in role_permissions))
# 获取路径规则
path_rules = []
if role_ids:
rules = await PathRule.filter(role_id__in=role_ids)
path_rules = [
PathRuleInfo(
id=r.id,
role_id=r.role_id,
path_pattern=r.path_pattern,
is_regex=r.is_regex,
can_read=r.can_read,
can_write=r.can_write,
can_delete=r.can_delete,
can_share=r.can_share,
priority=r.priority,
created_at=r.created_at,
)
for r in rules
]
return UserPermissions(
user_id=user_id,
is_admin=False,
permissions=permissions,
path_rules=path_rules,
)
@classmethod
async def get_all_permissions(cls) -> List[PermissionInfo]:
"""获取所有权限定义"""
permissions = await Permission.all()
return [
PermissionInfo(
id=p.id,
code=p.code,
name=p.name,
category=p.category,
description=p.description,
)
for p in permissions
]
@classmethod
async def check_path_permission_detailed(
cls, user_id: int, path: str, action: str
) -> PathPermissionResult:
"""检查路径权限并返回详细结果"""
user = await UserAccount.get_or_none(id=user_id)
if not user:
return PathPermissionResult(path=path, action=action, allowed=False)
# 超级管理员
if user.is_admin:
return PathPermissionResult(path=path, action=action, allowed=True)
# 获取用户角色
user_roles = await UserRole.filter(user_id=user_id)
role_ids = [ur.role_id for ur in user_roles]
if not role_ids:
return PathPermissionResult(path=path, action=action, allowed=False)
# 获取路径规则
path_rules = await PathRule.filter(role_id__in=role_ids).order_by("-priority")
normalized_path = PathMatcher.normalize_path(path)
# 查找匹配的规则
matched_rule = None
for rule in sorted(
path_rules,
key=lambda r: (
r.priority,
PathMatcher.get_pattern_specificity(r.path_pattern, r.is_regex),
),
reverse=True,
):
if PathMatcher.match_pattern(
normalized_path, rule.path_pattern, rule.is_regex
):
matched_rule = rule
break
# 检查权限
allowed = False
if matched_rule:
if action == PathAction.READ:
allowed = matched_rule.can_read
elif action == PathAction.WRITE:
allowed = matched_rule.can_write
elif action == PathAction.DELETE:
allowed = matched_rule.can_delete
elif action == PathAction.SHARE:
allowed = matched_rule.can_share
rule_info = None
if matched_rule:
rule_info = PathRuleInfo(
id=matched_rule.id,
role_id=matched_rule.role_id,
path_pattern=matched_rule.path_pattern,
is_regex=matched_rule.is_regex,
can_read=matched_rule.can_read,
can_write=matched_rule.can_write,
can_delete=matched_rule.can_delete,
can_share=matched_rule.can_share,
priority=matched_rule.priority,
created_at=matched_rule.created_at,
)
return PathPermissionResult(
path=path, action=action, allowed=allowed, matched_rule=rule_info
)
@classmethod
def clear_cache(cls, user_id: int | None = None) -> None:
"""清除权限缓存"""
if user_id is None:
cls._cache.clear()
else:
# 清除特定用户的缓存
keys_to_delete = [k for k in cls._cache if k.startswith(f"{user_id}:")]
for k in keys_to_delete:
del cls._cache[k]
@classmethod
async def filter_paths_by_permission(
cls, user_id: int, paths: List[str], action: str
) -> List[str]:
"""过滤出用户有权限的路径列表"""
result = []
for path in paths:
if await cls.check_path_permission(user_id, path, action):
result.append(path)
return result
+108
View File
@@ -0,0 +1,108 @@
from pydantic import BaseModel
from datetime import datetime
# 权限操作类型
class PathAction:
READ = "read"
WRITE = "write"
DELETE = "delete"
SHARE = "share"
# 系统权限代码
class SystemPermission:
USER_CREATE = "system.user.create"
USER_EDIT = "system.user.edit"
USER_DELETE = "system.user.delete"
USER_LIST = "system.user.list"
ROLE_MANAGE = "system.role.manage"
CONFIG_EDIT = "system.config.edit"
AUDIT_VIEW = "system.audit.view"
# 适配器权限代码
class AdapterPermission:
CREATE = "adapter.create"
EDIT = "adapter.edit"
DELETE = "adapter.delete"
LIST = "adapter.list"
# 所有权限定义
PERMISSION_DEFINITIONS = [
# 系统权限
{"code": SystemPermission.USER_CREATE, "name": "创建用户", "category": "system", "description": "允许创建新用户"},
{"code": SystemPermission.USER_EDIT, "name": "编辑用户", "category": "system", "description": "允许编辑用户信息"},
{"code": SystemPermission.USER_DELETE, "name": "删除用户", "category": "system", "description": "允许删除用户"},
{"code": SystemPermission.USER_LIST, "name": "查看用户列表", "category": "system", "description": "允许查看用户列表"},
{"code": SystemPermission.ROLE_MANAGE, "name": "管理角色和权限", "category": "system", "description": "允许管理角色和权限配置"},
{"code": SystemPermission.CONFIG_EDIT, "name": "修改系统配置", "category": "system", "description": "允许修改系统配置"},
{"code": SystemPermission.AUDIT_VIEW, "name": "查看审计日志", "category": "system", "description": "允许查看审计日志"},
# 适配器权限
{"code": AdapterPermission.CREATE, "name": "创建存储适配器", "category": "adapter", "description": "允许创建存储适配器"},
{"code": AdapterPermission.EDIT, "name": "编辑存储适配器", "category": "adapter", "description": "允许编辑存储适配器"},
{"code": AdapterPermission.DELETE, "name": "删除存储适配器", "category": "adapter", "description": "允许删除存储适配器"},
{"code": AdapterPermission.LIST, "name": "查看存储适配器列表", "category": "adapter", "description": "允许查看存储适配器列表"},
]
# Pydantic 模型
class PermissionInfo(BaseModel):
id: int
code: str
name: str
category: str
description: str | None = None
class PathRuleInfo(BaseModel):
id: int
role_id: int
path_pattern: str
is_regex: bool
can_read: bool
can_write: bool
can_delete: bool
can_share: bool
priority: int
created_at: datetime
class PathRuleCreate(BaseModel):
path_pattern: str
is_regex: bool = False
can_read: bool = True
can_write: bool = False
can_delete: bool = False
can_share: bool = False
priority: int = 0
class PathRuleUpdate(BaseModel):
path_pattern: str | None = None
is_regex: bool | None = None
can_read: bool | None = None
can_write: bool | None = None
can_delete: bool | None = None
can_share: bool | None = None
priority: int | None = None
class PathPermissionCheck(BaseModel):
path: str
action: str
class PathPermissionResult(BaseModel):
path: str
action: str
allowed: bool
matched_rule: PathRuleInfo | None = None
class UserPermissions(BaseModel):
user_id: int
is_admin: bool
permissions: list[str] # 系统/适配器权限代码列表
path_rules: list[PathRuleInfo] # 路径权限规则
+3
View File
@@ -0,0 +1,3 @@
from .service import RoleService
__all__ = ["RoleService"]
+142
View File
@@ -0,0 +1,142 @@
from typing import Annotated
from fastapi import APIRouter, Depends
from domain.auth.service import get_current_active_user
from domain.auth.types import User
from domain.permission.service import PermissionService
from domain.permission.types import PathRuleCreate, PathRuleInfo, SystemPermission
from domain.user.service import UserService
from domain.user.types import UserInfo
from .service import RoleService
from .types import RoleCreate, RoleDetail, RoleInfo, RolePermissionsUpdate, RoleUpdate
router = APIRouter(prefix="/api", tags=["role"])
@router.get("/roles", response_model=list[RoleInfo])
async def list_roles(
current_user: Annotated[User, Depends(get_current_active_user)]
) -> list[RoleInfo]:
await PermissionService.require_system_permission(
current_user.id, SystemPermission.ROLE_MANAGE
)
return await RoleService.get_all_roles()
@router.get("/roles/{role_id}", response_model=RoleDetail)
async def get_role(
role_id: int,
current_user: Annotated[User, Depends(get_current_active_user)],
) -> RoleDetail:
await PermissionService.require_system_permission(
current_user.id, SystemPermission.ROLE_MANAGE
)
return await RoleService.get_role(role_id)
@router.get("/roles/{role_id}/users", response_model=list[UserInfo])
async def list_role_users(
role_id: int,
current_user: Annotated[User, Depends(get_current_active_user)],
) -> list[UserInfo]:
await PermissionService.require_system_permission(
current_user.id, SystemPermission.ROLE_MANAGE
)
return await UserService.get_users_by_role(role_id)
@router.post("/roles", response_model=RoleInfo)
async def create_role(
data: RoleCreate,
current_user: Annotated[User, Depends(get_current_active_user)],
) -> RoleInfo:
await PermissionService.require_system_permission(
current_user.id, SystemPermission.ROLE_MANAGE
)
return await RoleService.create_role(data)
@router.put("/roles/{role_id}", response_model=RoleInfo)
async def update_role(
role_id: int,
data: RoleUpdate,
current_user: Annotated[User, Depends(get_current_active_user)],
) -> RoleInfo:
await PermissionService.require_system_permission(
current_user.id, SystemPermission.ROLE_MANAGE
)
return await RoleService.update_role(role_id, data)
@router.delete("/roles/{role_id}")
async def delete_role(
role_id: int,
current_user: Annotated[User, Depends(get_current_active_user)],
) -> dict:
await PermissionService.require_system_permission(
current_user.id, SystemPermission.ROLE_MANAGE
)
await RoleService.delete_role(role_id)
return {"success": True}
@router.post("/roles/{role_id}/permissions", response_model=list[str])
async def set_role_permissions(
role_id: int,
data: RolePermissionsUpdate,
current_user: Annotated[User, Depends(get_current_active_user)],
) -> list[str]:
await PermissionService.require_system_permission(
current_user.id, SystemPermission.ROLE_MANAGE
)
return await RoleService.set_role_permissions(role_id, data.permission_codes)
@router.get("/roles/{role_id}/path-rules", response_model=list[PathRuleInfo])
async def get_role_path_rules(
role_id: int,
current_user: Annotated[User, Depends(get_current_active_user)],
) -> list[PathRuleInfo]:
await PermissionService.require_system_permission(
current_user.id, SystemPermission.ROLE_MANAGE
)
return await RoleService.get_role_path_rules(role_id)
@router.post("/roles/{role_id}/path-rules", response_model=PathRuleInfo)
async def add_path_rule(
role_id: int,
data: PathRuleCreate,
current_user: Annotated[User, Depends(get_current_active_user)],
) -> PathRuleInfo:
await PermissionService.require_system_permission(
current_user.id, SystemPermission.ROLE_MANAGE
)
return await RoleService.add_path_rule(role_id, data)
@router.put("/path-rules/{rule_id}", response_model=PathRuleInfo)
async def update_path_rule(
rule_id: int,
data: PathRuleCreate,
current_user: Annotated[User, Depends(get_current_active_user)],
) -> PathRuleInfo:
await PermissionService.require_system_permission(
current_user.id, SystemPermission.ROLE_MANAGE
)
return await RoleService.update_path_rule(rule_id, data)
@router.delete("/path-rules/{rule_id}")
async def delete_path_rule(
rule_id: int,
current_user: Annotated[User, Depends(get_current_active_user)],
) -> dict:
await PermissionService.require_system_permission(
current_user.id, SystemPermission.ROLE_MANAGE
)
await RoleService.delete_path_rule(rule_id)
return {"success": True}
+327
View File
@@ -0,0 +1,327 @@
from typing import List
from fastapi import HTTPException
from models.database import Role, RolePermission, Permission, PathRule, UserRole
from domain.permission.service import PermissionService
from domain.permission.types import PathRuleCreate, PathRuleInfo
from .types import RoleInfo, RoleDetail, RoleCreate, RoleUpdate, SystemRoles
class RoleService:
"""角色管理服务"""
@classmethod
async def get_all_roles(cls) -> List[RoleInfo]:
"""获取所有角色"""
roles = await Role.all().order_by("id")
return [
RoleInfo(
id=r.id,
name=r.name,
description=r.description,
is_system=r.is_system,
created_at=r.created_at,
)
for r in roles
]
@classmethod
async def get_role(cls, role_id: int) -> RoleDetail:
"""获取角色详情"""
role = await Role.get_or_none(id=role_id)
if not role:
raise HTTPException(404, detail="角色不存在")
# 获取权限
role_permissions = await RolePermission.filter(role_id=role_id).prefetch_related(
"permission"
)
permissions = [rp.permission.code for rp in role_permissions]
# 获取路径规则数量
path_rules_count = await PathRule.filter(role_id=role_id).count()
return RoleDetail(
id=role.id,
name=role.name,
description=role.description,
is_system=role.is_system,
created_at=role.created_at,
permissions=permissions,
path_rules_count=path_rules_count,
)
@classmethod
async def create_role(cls, data: RoleCreate) -> RoleInfo:
"""创建角色"""
# 检查名称是否已存在
existing = await Role.get_or_none(name=data.name)
if existing:
raise HTTPException(400, detail="角色名称已存在")
role = await Role.create(
name=data.name,
description=data.description,
is_system=False,
)
return RoleInfo(
id=role.id,
name=role.name,
description=role.description,
is_system=role.is_system,
created_at=role.created_at,
)
@classmethod
async def update_role(cls, role_id: int, data: RoleUpdate) -> RoleInfo:
"""更新角色"""
role = await Role.get_or_none(id=role_id)
if not role:
raise HTTPException(404, detail="角色不存在")
if data.name is not None:
# 检查名称是否与其他角色冲突
existing = await Role.filter(name=data.name).exclude(id=role_id).first()
if existing:
raise HTTPException(400, detail="角色名称已存在")
role.name = data.name
if data.description is not None:
role.description = data.description
await role.save()
return RoleInfo(
id=role.id,
name=role.name,
description=role.description,
is_system=role.is_system,
created_at=role.created_at,
)
@classmethod
async def delete_role(cls, role_id: int) -> None:
"""删除角色"""
role = await Role.get_or_none(id=role_id)
if not role:
raise HTTPException(404, detail="角色不存在")
if role.is_system:
raise HTTPException(400, detail="系统内置角色不可删除")
# 检查是否有用户使用此角色
user_count = await UserRole.filter(role_id=role_id).count()
if user_count > 0:
raise HTTPException(400, detail=f"{user_count} 个用户正在使用此角色,无法删除")
await role.delete()
# 清除权限缓存
PermissionService.clear_cache()
@classmethod
async def set_role_permissions(cls, role_id: int, permission_codes: List[str]) -> List[str]:
"""设置角色的权限"""
role = await Role.get_or_none(id=role_id)
if not role:
raise HTTPException(404, detail="角色不存在")
# 获取权限ID
permissions = await Permission.filter(code__in=permission_codes)
permission_map = {p.code: p.id for p in permissions}
# 验证所有权限代码
invalid_codes = set(permission_codes) - set(permission_map.keys())
if invalid_codes:
raise HTTPException(400, detail=f"无效的权限代码: {', '.join(invalid_codes)}")
# 删除现有权限
await RolePermission.filter(role_id=role_id).delete()
# 添加新权限
for code in permission_codes:
await RolePermission.create(
role_id=role_id,
permission_id=permission_map[code],
)
# 清除权限缓存
PermissionService.clear_cache()
return permission_codes
@classmethod
async def get_role_path_rules(cls, role_id: int) -> List[PathRuleInfo]:
"""获取角色的路径规则"""
role = await Role.get_or_none(id=role_id)
if not role:
raise HTTPException(404, detail="角色不存在")
rules = await PathRule.filter(role_id=role_id).order_by("-priority", "id")
return [
PathRuleInfo(
id=r.id,
role_id=r.role_id,
path_pattern=r.path_pattern,
is_regex=r.is_regex,
can_read=r.can_read,
can_write=r.can_write,
can_delete=r.can_delete,
can_share=r.can_share,
priority=r.priority,
created_at=r.created_at,
)
for r in rules
]
@classmethod
async def add_path_rule(cls, role_id: int, data: PathRuleCreate) -> PathRuleInfo:
"""添加路径规则"""
role = await Role.get_or_none(id=role_id)
if not role:
raise HTTPException(404, detail="角色不存在")
# 验证路径模式
if data.is_regex:
import re
try:
re.compile(data.path_pattern)
except re.error as e:
raise HTTPException(400, detail=f"无效的正则表达式: {e}")
rule = await PathRule.create(
role_id=role_id,
path_pattern=data.path_pattern,
is_regex=data.is_regex,
can_read=data.can_read,
can_write=data.can_write,
can_delete=data.can_delete,
can_share=data.can_share,
priority=data.priority,
)
# 清除权限缓存
PermissionService.clear_cache()
return PathRuleInfo(
id=rule.id,
role_id=rule.role_id,
path_pattern=rule.path_pattern,
is_regex=rule.is_regex,
can_read=rule.can_read,
can_write=rule.can_write,
can_delete=rule.can_delete,
can_share=rule.can_share,
priority=rule.priority,
created_at=rule.created_at,
)
@classmethod
async def update_path_rule(cls, rule_id: int, data: PathRuleCreate) -> PathRuleInfo:
"""更新路径规则"""
rule = await PathRule.get_or_none(id=rule_id)
if not rule:
raise HTTPException(404, detail="路径规则不存在")
# 验证路径模式
if data.is_regex:
import re
try:
re.compile(data.path_pattern)
except re.error as e:
raise HTTPException(400, detail=f"无效的正则表达式: {e}")
rule.path_pattern = data.path_pattern
rule.is_regex = data.is_regex
rule.can_read = data.can_read
rule.can_write = data.can_write
rule.can_delete = data.can_delete
rule.can_share = data.can_share
rule.priority = data.priority
await rule.save()
# 清除权限缓存
PermissionService.clear_cache()
return PathRuleInfo(
id=rule.id,
role_id=rule.role_id,
path_pattern=rule.path_pattern,
is_regex=rule.is_regex,
can_read=rule.can_read,
can_write=rule.can_write,
can_delete=rule.can_delete,
can_share=rule.can_share,
priority=rule.priority,
created_at=rule.created_at,
)
@classmethod
async def delete_path_rule(cls, rule_id: int) -> None:
"""删除路径规则"""
rule = await PathRule.get_or_none(id=rule_id)
if not rule:
raise HTTPException(404, detail="路径规则不存在")
await rule.delete()
# 清除权限缓存
PermissionService.clear_cache()
@classmethod
async def ensure_system_roles(cls) -> None:
"""确保系统内置角色存在"""
system_roles = [
{
"name": SystemRoles.ADMIN,
"description": "管理员角色,拥有所有系统和适配器权限",
"is_system": True,
},
{
"name": SystemRoles.USER,
"description": "普通用户角色,需要管理员配置路径权限",
"is_system": True,
},
{
"name": SystemRoles.VIEWER,
"description": "只读用户角色,仅可查看文件",
"is_system": True,
},
]
for role_data in system_roles:
existing = await Role.get_or_none(name=role_data["name"])
if not existing:
await Role.create(**role_data)
@classmethod
async def setup_admin_role_permissions(cls) -> None:
"""为管理员角色设置所有权限"""
admin_role = await Role.get_or_none(name=SystemRoles.ADMIN)
if not admin_role:
return
# 获取所有权限
all_permissions = await Permission.all()
# 清除现有权限
await RolePermission.filter(role_id=admin_role.id).delete()
# 添加所有权限
for perm in all_permissions:
await RolePermission.create(role_id=admin_role.id, permission_id=perm.id)
# 添加全路径访问规则
existing_rule = await PathRule.filter(
role_id=admin_role.id, path_pattern="/**"
).first()
if not existing_rule:
await PathRule.create(
role_id=admin_role.id,
path_pattern="/**",
is_regex=False,
can_read=True,
can_write=True,
can_delete=True,
can_share=True,
priority=100,
)
+36
View File
@@ -0,0 +1,36 @@
from pydantic import BaseModel
from datetime import datetime
class RoleInfo(BaseModel):
id: int
name: str
description: str | None = None
is_system: bool
created_at: datetime
class RoleDetail(RoleInfo):
permissions: list[str] # 权限代码列表
path_rules_count: int
class RoleCreate(BaseModel):
name: str
description: str | None = None
class RoleUpdate(BaseModel):
name: str | None = None
description: str | None = None
class RolePermissionsUpdate(BaseModel):
permission_codes: list[str]
# 预置角色名称
class SystemRoles:
ADMIN = "Admin"
USER = "User"
VIEWER = "Viewer"
+4
View File
@@ -0,0 +1,4 @@
from .service import UserService
__all__ = ["UserService"]
+94
View File
@@ -0,0 +1,94 @@
from typing import Annotated
from fastapi import APIRouter, Depends
from domain.auth.service import get_current_active_user
from domain.auth.types import User
from domain.permission.service import PermissionService
from domain.permission.types import SystemPermission
from .service import UserService
from .types import UserCreate, UserDetail, UserInfo, UserRoleAssign, UserUpdate
router = APIRouter(prefix="/api", tags=["user"])
@router.get("/users", response_model=list[UserInfo])
async def list_users(
current_user: Annotated[User, Depends(get_current_active_user)]
) -> list[UserInfo]:
await PermissionService.require_system_permission(
current_user.id, SystemPermission.USER_LIST
)
return await UserService.get_all_users()
@router.get("/users/{user_id}", response_model=UserDetail)
async def get_user(
user_id: int,
current_user: Annotated[User, Depends(get_current_active_user)],
) -> UserDetail:
await PermissionService.require_system_permission(
current_user.id, SystemPermission.USER_LIST
)
return await UserService.get_user(user_id)
@router.post("/users", response_model=UserDetail)
async def create_user(
data: UserCreate,
current_user: Annotated[User, Depends(get_current_active_user)],
) -> UserDetail:
await PermissionService.require_system_permission(
current_user.id, SystemPermission.USER_CREATE
)
return await UserService.create_user(data, current_user.id)
@router.put("/users/{user_id}", response_model=UserDetail)
async def update_user(
user_id: int,
data: UserUpdate,
current_user: Annotated[User, Depends(get_current_active_user)],
) -> UserDetail:
await PermissionService.require_system_permission(
current_user.id, SystemPermission.USER_EDIT
)
return await UserService.update_user(user_id, data, current_user.id)
@router.delete("/users/{user_id}")
async def delete_user(
user_id: int,
current_user: Annotated[User, Depends(get_current_active_user)],
) -> dict:
await PermissionService.require_system_permission(
current_user.id, SystemPermission.USER_DELETE
)
await UserService.delete_user(user_id, current_user.id)
return {"success": True}
@router.post("/users/{user_id}/roles", response_model=list[str])
async def set_user_roles(
user_id: int,
data: UserRoleAssign,
current_user: Annotated[User, Depends(get_current_active_user)],
) -> list[str]:
await PermissionService.require_system_permission(
current_user.id, SystemPermission.USER_EDIT
)
return await UserService.set_user_roles(user_id, data.role_ids)
@router.delete("/users/{user_id}/roles/{role_id}", response_model=list[str])
async def remove_user_role(
user_id: int,
role_id: int,
current_user: Annotated[User, Depends(get_current_active_user)],
) -> list[str]:
await PermissionService.require_system_permission(
current_user.id, SystemPermission.USER_EDIT
)
return await UserService.remove_user_role(user_id, role_id)
+190
View File
@@ -0,0 +1,190 @@
from typing import List
from fastapi import HTTPException
from domain.auth.service import AuthService
from domain.permission.service import PermissionService
from models.database import Role, UserAccount, UserRole
from .types import UserCreate, UserDetail, UserInfo, UserUpdate
class UserService:
"""用户管理服务"""
@classmethod
async def get_all_users(cls) -> List[UserInfo]:
users = await UserAccount.all().order_by("id")
return [
UserInfo(
id=u.id,
username=u.username,
email=u.email,
full_name=u.full_name,
disabled=u.disabled,
is_admin=u.is_admin,
created_at=u.created_at,
last_login=u.last_login,
)
for u in users
]
@classmethod
async def get_user(cls, user_id: int) -> UserDetail:
user = await UserAccount.get_or_none(id=user_id).prefetch_related("created_by")
if not user:
raise HTTPException(404, detail="用户不存在")
user_roles = await UserRole.filter(user_id=user_id).prefetch_related("role")
roles = [ur.role.name for ur in user_roles]
created_by_username = None
if user.created_by_id:
creator = await UserAccount.get_or_none(id=user.created_by_id)
if creator:
created_by_username = creator.username
return UserDetail(
id=user.id,
username=user.username,
email=user.email,
full_name=user.full_name,
disabled=user.disabled,
is_admin=user.is_admin,
created_at=user.created_at,
last_login=user.last_login,
roles=roles,
created_by_username=created_by_username,
)
@classmethod
async def get_users_by_role(cls, role_id: int) -> List[UserInfo]:
role = await Role.get_or_none(id=role_id)
if not role:
raise HTTPException(404, detail="角色不存在")
user_roles = await UserRole.filter(role_id=role_id).prefetch_related("user")
users = [ur.user for ur in user_roles if ur.user]
users.sort(key=lambda u: u.id)
return [
UserInfo(
id=u.id,
username=u.username,
email=u.email,
full_name=u.full_name,
disabled=u.disabled,
is_admin=u.is_admin,
created_at=u.created_at,
last_login=u.last_login,
)
for u in users
]
@classmethod
async def create_user(cls, data: UserCreate, creator_id: int) -> UserDetail:
existing = await UserAccount.get_or_none(username=data.username)
if existing:
raise HTTPException(400, detail="用户名已存在")
if data.email:
existing_email = await UserAccount.get_or_none(email=data.email)
if existing_email:
raise HTTPException(400, detail="邮箱已被使用")
hashed_password = AuthService.get_password_hash(data.password)
user = await UserAccount.create(
username=data.username,
email=data.email,
full_name=data.full_name,
hashed_password=hashed_password,
disabled=data.disabled,
is_admin=data.is_admin,
created_by_id=creator_id,
)
if data.role_ids:
for role_id in data.role_ids:
role = await Role.get_or_none(id=role_id)
if role:
await UserRole.create(user_id=user.id, role_id=role_id)
return await cls.get_user(user.id)
@classmethod
async def update_user(cls, user_id: int, data: UserUpdate, operator_id: int) -> UserDetail:
user = await UserAccount.get_or_none(id=user_id)
if not user:
raise HTTPException(404, detail="用户不存在")
if data.is_admin is not None and user_id == operator_id:
raise HTTPException(400, detail="不能修改自己的管理员状态")
if data.email is not None:
existing = await UserAccount.filter(email=data.email).exclude(id=user_id).first()
if existing:
raise HTTPException(400, detail="邮箱已被使用")
user.email = data.email
if data.full_name is not None:
user.full_name = data.full_name
if data.password is not None:
user.hashed_password = AuthService.get_password_hash(data.password)
if data.is_admin is not None:
user.is_admin = data.is_admin
if data.disabled is not None:
if user_id == operator_id and data.disabled:
raise HTTPException(400, detail="不能禁用自己")
user.disabled = data.disabled
await user.save()
PermissionService.clear_cache(user_id)
return await cls.get_user(user_id)
@classmethod
async def delete_user(cls, user_id: int, operator_id: int) -> None:
if user_id == operator_id:
raise HTTPException(400, detail="不能删除自己")
user = await UserAccount.get_or_none(id=user_id)
if not user:
raise HTTPException(404, detail="用户不存在")
await UserRole.filter(user_id=user_id).delete()
await user.delete()
PermissionService.clear_cache(user_id)
@classmethod
async def set_user_roles(cls, user_id: int, role_ids: List[int]) -> List[str]:
user = await UserAccount.get_or_none(id=user_id)
if not user:
raise HTTPException(404, detail="用户不存在")
roles = await Role.filter(id__in=role_ids)
valid_role_ids = {r.id for r in roles}
invalid_ids = set(role_ids) - valid_role_ids
if invalid_ids:
raise HTTPException(400, detail=f"无效的角色ID: {invalid_ids}")
await UserRole.filter(user_id=user_id).delete()
for role_id in role_ids:
await UserRole.create(user_id=user_id, role_id=role_id)
PermissionService.clear_cache(user_id)
return [r.name for r in roles if r.id in role_ids]
@classmethod
async def remove_user_role(cls, user_id: int, role_id: int) -> List[str]:
user = await UserAccount.get_or_none(id=user_id)
if not user:
raise HTTPException(404, detail="用户不存在")
await UserRole.filter(user_id=user_id, role_id=role_id).delete()
PermissionService.clear_cache(user_id)
user_roles = await UserRole.filter(user_id=user_id).prefetch_related("role")
return [ur.role.name for ur in user_roles]
+42
View File
@@ -0,0 +1,42 @@
from datetime import datetime
from pydantic import BaseModel
class UserInfo(BaseModel):
id: int
username: str
email: str | None = None
full_name: str | None = None
disabled: bool
is_admin: bool
created_at: datetime
last_login: datetime | None = None
class UserDetail(UserInfo):
roles: list[str]
created_by_username: str | None = None
class UserCreate(BaseModel):
username: str
password: str
email: str | None = None
full_name: str | None = None
is_admin: bool = False
disabled: bool = False
role_ids: list[int] = []
class UserUpdate(BaseModel):
email: str | None = None
full_name: str | None = None
password: str | None = None
is_admin: bool | None = None
disabled: bool | None = None
class UserRoleAssign(BaseModel):
role_ids: list[int]
+25 -2
View File
@@ -5,6 +5,8 @@ from fastapi import APIRouter, Depends, File, Query, Request, UploadFile
from api.response import success
from domain.audit import AuditAction, audit
from domain.auth import User, get_current_active_user
from domain.permission.service import PermissionService
from domain.permission.types import PathAction
from .service import VirtualFSService
from .types import MkdirRequest, MoveRequest
@@ -18,6 +20,7 @@ async def get_file(
request: Request,
current_user: Annotated[User, Depends(get_current_active_user)],
):
await PermissionService.require_path_permission(current_user.id, full_path, PathAction.READ)
return await VirtualFSService.serve_file(full_path, request.headers.get("Range"))
@@ -50,6 +53,7 @@ async def get_temp_link(
current_user: Annotated[User, Depends(get_current_active_user)],
expires_in: int = Query(3600, description="有效时间(秒), 0或负数表示永久"),
):
await PermissionService.require_path_permission(current_user.id, full_path, PathAction.SHARE)
data = await VirtualFSService.create_temp_link(full_path, expires_in)
return success(data)
@@ -80,6 +84,7 @@ async def get_file_stat(
request: Request,
current_user: Annotated[User, Depends(get_current_active_user)],
):
await PermissionService.require_path_permission(current_user.id, full_path, PathAction.READ)
stat = await VirtualFSService.stat(full_path)
return success(stat)
@@ -92,6 +97,7 @@ async def put_file(
full_path: str,
file: UploadFile = File(...),
):
await PermissionService.require_path_permission(current_user.id, full_path, PathAction.WRITE)
data = await file.read()
result = await VirtualFSService.write_uploaded_file(full_path, data)
return success(result)
@@ -104,6 +110,7 @@ async def api_mkdir(
current_user: Annotated[User, Depends(get_current_active_user)],
body: MkdirRequest,
):
await PermissionService.require_path_permission(current_user.id, body.path, PathAction.WRITE)
result = await VirtualFSService.mkdir(body.path)
return success(result)
@@ -116,6 +123,9 @@ async def api_move(
body: MoveRequest,
overwrite: bool = Query(False, description="是否允许覆盖已存在目标"),
):
# 移动需要源路径的删除权限和目标路径的写权限
await PermissionService.require_path_permission(current_user.id, body.src, PathAction.DELETE)
await PermissionService.require_path_permission(current_user.id, body.dst, PathAction.WRITE)
result = await VirtualFSService.move(body.src, body.dst, overwrite)
return success(result)
@@ -128,6 +138,8 @@ async def api_rename(
body: MoveRequest,
overwrite: bool = Query(False, description="是否允许覆盖已存在目标"),
):
# 重命名需要写权限
await PermissionService.require_path_permission(current_user.id, body.src, PathAction.WRITE)
result = await VirtualFSService.rename(body.src, body.dst, overwrite)
return success(result)
@@ -140,6 +152,9 @@ async def api_copy(
body: MoveRequest,
overwrite: bool = Query(False, description="是否覆盖已存在目标"),
):
# 复制需要源路径的读权限和目标路径的写权限
await PermissionService.require_path_permission(current_user.id, body.src, PathAction.READ)
await PermissionService.require_path_permission(current_user.id, body.dst, PathAction.WRITE)
result = await VirtualFSService.copy(body.src, body.dst, overwrite)
return success(result)
@@ -154,6 +169,7 @@ async def upload_stream(
overwrite: bool = Query(True, description="是否覆盖已存在文件"),
chunk_size: int = Query(1024 * 1024, ge=8 * 1024, le=8 * 1024 * 1024, description="单次读取块大小"),
):
await PermissionService.require_path_permission(current_user.id, full_path, PathAction.WRITE)
result = await VirtualFSService.upload_stream_from_upload_file(full_path, file, chunk_size, overwrite)
return success(result)
@@ -169,7 +185,10 @@ async def browse_fs(
sort_by: str = Query("name", description="按字段排序: name, size, mtime"),
sort_order: str = Query("asc", description="排序顺序: asc, desc"),
):
data = await VirtualFSService.list_directory(full_path, page_num, page_size, sort_by, sort_order)
await PermissionService.require_path_permission(current_user.id, full_path, PathAction.READ)
data = await VirtualFSService.list_directory_with_permission(
full_path, current_user.id, page_num, page_size, sort_by, sort_order
)
return success(data)
@@ -180,6 +199,7 @@ async def api_delete(
current_user: Annotated[User, Depends(get_current_active_user)],
full_path: str,
):
await PermissionService.require_path_permission(current_user.id, full_path, PathAction.DELETE)
result = await VirtualFSService.delete(full_path)
return success(result)
@@ -194,5 +214,8 @@ async def root_listing(
sort_by: str = Query("name", description="按字段排序: name, size, mtime"),
sort_order: str = Query("asc", description="排序顺序: asc, desc"),
):
data = await VirtualFSService.list_directory("/", page_num, page_size, sort_by, sort_order)
# 根目录不需要权限检查,但需要过滤无权限的子目录
data = await VirtualFSService.list_directory_with_permission(
"/", current_user.id, page_num, page_size, sort_by, sort_order
)
return success(data)
+53
View File
@@ -5,6 +5,8 @@ from fastapi import HTTPException
from api.response import page
from domain.adapters import runtime_registry
from domain.ai import FILE_COLLECTION_NAME, VECTOR_COLLECTION_NAME, VectorDBService
from domain.permission.service import PermissionService
from domain.permission.types import PathAction
from .thumbnail import is_image_filename, is_video_filename
from models import StorageAdapter
@@ -245,3 +247,54 @@ class VirtualFSListingMixin(VirtualFSResolverMixin):
info["vector_index"] = vector_index
return info
@classmethod
async def list_virtual_dir_with_permission(
cls,
path: str,
user_id: int,
page_num: int = 1,
page_size: int = 50,
sort_by: str = "name",
sort_order: str = "asc",
) -> Dict:
"""
带权限过滤的目录列表
过滤掉用户没有读取权限的条目
"""
# 首先获取完整的目录列表
result = await cls.list_virtual_dir(path, page_num, page_size, sort_by, sort_order)
# 检查用户是否是管理员(管理员可以看到所有内容)
from models.database import UserAccount
user = await UserAccount.get_or_none(id=user_id)
if user and user.is_admin:
return result
# 过滤无权限的条目
items = result.get("items", [])
if not items:
return result
norm = cls._normalize_path(path).rstrip("/") or "/"
filtered_items = []
for item in items:
item_name = item.get("name", "")
if norm == "/":
item_path = f"/{item_name}"
else:
item_path = f"{norm}/{item_name}"
# 检查用户是否有读取权限
has_permission = await PermissionService.check_path_permission(
user_id, item_path, PathAction.READ
)
if has_permission:
filtered_items.append(item)
# 更新结果
result["items"] = filtered_items
return result
+37 -1
View File
@@ -18,4 +18,40 @@ class VirtualFSService(
VirtualFSResolverMixin,
VirtualFSCommonMixin,
):
pass
@classmethod
async def list_directory(
cls,
path: str,
page_num: int = 1,
page_size: int = 50,
sort_by: str = "name",
sort_order: str = "asc",
):
"""列出目录内容"""
return await cls.list_virtual_dir(path, page_num, page_size, sort_by, sort_order)
@classmethod
async def list_directory_with_permission(
cls,
path: str,
user_id: int,
page_num: int = 1,
page_size: int = 50,
sort_by: str = "name",
sort_order: str = "asc",
):
"""列出目录内容(带权限过滤)"""
full_path = cls._normalize_path(path).rstrip("/") or "/"
result = await cls.list_virtual_dir_with_permission(
full_path, user_id, page_num, page_size, sort_by, sort_order
)
return {
"path": full_path,
"entries": result.get("items", []) if isinstance(result, dict) else [],
"pagination": {
"total": result.get("total", 0) if isinstance(result, dict) else 0,
"page": result.get("page", page_num) if isinstance(result, dict) else page_num,
"page_size": result.get("page_size", page_size) if isinstance(result, dict) else page_size,
"pages": result.get("pages", 0) if isinstance(result, dict) else 0,
},
}
+18 -2
View File
@@ -1,3 +1,19 @@
from .database import StorageAdapter
from .database import (
StorageAdapter,
UserAccount,
Role,
UserRole,
Permission,
RolePermission,
PathRule,
)
__all__ = ["StorageAdapter"]
__all__ = [
"StorageAdapter",
"UserAccount",
"Role",
"UserRole",
"Permission",
"RolePermission",
"PathRule",
]
+84
View File
@@ -22,11 +22,95 @@ class UserAccount(Model):
full_name = fields.CharField(max_length=100, null=True)
hashed_password = fields.CharField(max_length=128)
disabled = fields.BooleanField(default=False)
is_admin = fields.BooleanField(default=False)
created_by: fields.ForeignKeyNullableRelation["UserAccount"] = fields.ForeignKeyField(
"models.UserAccount", null=True, related_name="created_users", on_delete=fields.SET_NULL
)
created_at = fields.DatetimeField(auto_now_add=True)
last_login = fields.DatetimeField(null=True)
class Meta:
table = "user"
class Role(Model):
"""角色表"""
id = fields.IntField(pk=True)
name = fields.CharField(max_length=50, unique=True) # 角色名称
description = fields.CharField(max_length=255, null=True)
is_system = fields.BooleanField(default=False) # 系统内置角色不可删除
created_at = fields.DatetimeField(auto_now_add=True)
class Meta:
table = "roles"
class UserRole(Model):
"""用户-角色关联表"""
id = fields.IntField(pk=True)
user: fields.ForeignKeyRelation[UserAccount] = fields.ForeignKeyField(
"models.UserAccount", related_name="user_roles", on_delete=fields.CASCADE
)
role: fields.ForeignKeyRelation[Role] = fields.ForeignKeyField(
"models.Role", related_name="role_users", on_delete=fields.CASCADE
)
class Meta:
table = "user_roles"
unique_together = (("user", "role"),)
class Permission(Model):
"""权限定义表"""
id = fields.IntField(pk=True)
code = fields.CharField(max_length=50, unique=True) # 权限代码
name = fields.CharField(max_length=100) # 权限名称
category = fields.CharField(max_length=50) # 分类:system/adapter/file
description = fields.CharField(max_length=255, null=True)
class Meta:
table = "permissions"
class RolePermission(Model):
"""角色-权限关联表"""
id = fields.IntField(pk=True)
role: fields.ForeignKeyRelation[Role] = fields.ForeignKeyField(
"models.Role", related_name="role_permissions", on_delete=fields.CASCADE
)
permission: fields.ForeignKeyRelation[Permission] = fields.ForeignKeyField(
"models.Permission", related_name="permission_roles", on_delete=fields.CASCADE
)
class Meta:
table = "role_permissions"
unique_together = (("role", "permission"),)
class PathRule(Model):
"""路径权限规则表"""
id = fields.IntField(pk=True)
role: fields.ForeignKeyRelation[Role] = fields.ForeignKeyField(
"models.Role", related_name="path_rules", on_delete=fields.CASCADE
)
path_pattern = fields.CharField(max_length=512) # 路径模式
is_regex = fields.BooleanField(default=False) # 是否为正则表达式
can_read = fields.BooleanField(default=True)
can_write = fields.BooleanField(default=False)
can_delete = fields.BooleanField(default=False)
can_share = fields.BooleanField(default=False)
priority = fields.IntField(default=0) # 优先级,数值越大优先级越高
created_at = fields.DatetimeField(auto_now_add=True)
class Meta:
table = "path_rules"
class Configuration(Model):
id = fields.IntField(pk=True)
key = fields.CharField(max_length=100, unique=True)
+129
View File
@@ -15,6 +15,8 @@ if str(PROJECT_ROOT) not in sys.path:
from domain.config import VERSION
from domain.auth import get_password_hash
from domain.permission.types import PERMISSION_DEFINITIONS
from domain.role.types import SystemRoles
def _project_root() -> Path:
@@ -127,6 +129,129 @@ def _cmd_reset_password(args: argparse.Namespace) -> int:
return 0
def _cmd_init_rbac(args: argparse.Namespace) -> int:
db_path = Path(args.db).expanduser() if args.db else _default_db_path()
role_definitions = [
{
"name": SystemRoles.ADMIN,
"description": "管理员角色,拥有所有系统和适配器权限",
},
{
"name": SystemRoles.USER,
"description": "普通用户角色,需要管理员配置路径权限",
},
{
"name": SystemRoles.VIEWER,
"description": "只读用户角色,仅可查看文件",
},
]
conn = sqlite3.connect(str(db_path))
try:
conn.execute("PRAGMA foreign_keys = ON")
cursor = conn.cursor()
try:
cursor.execute("SELECT 1 FROM permissions LIMIT 1")
cursor.execute("SELECT 1 FROM roles LIMIT 1")
cursor.execute("SELECT 1 FROM role_permissions LIMIT 1")
cursor.execute("SELECT 1 FROM path_rules LIMIT 1")
except sqlite3.OperationalError as exc:
print(f"数据库未初始化(缺少表)。请先启动一次服务生成表。{exc}", file=sys.stderr)
return 1
# upsert permissions
for perm in PERMISSION_DEFINITIONS:
cursor.execute(
"""
INSERT INTO permissions (code, name, category, description)
VALUES (?, ?, ?, ?)
ON CONFLICT(code) DO UPDATE SET
name = excluded.name,
category = excluded.category,
description = excluded.description
""",
(
perm["code"],
perm["name"],
perm["category"],
perm.get("description"),
),
)
# upsert roles
for role in role_definitions:
cursor.execute(
"""
INSERT INTO roles (name, description, is_system)
VALUES (?, ?, 1)
ON CONFLICT(name) DO UPDATE SET
description = excluded.description,
is_system = 1
""",
(role["name"], role["description"]),
)
# grant all permissions to Admin role
cursor.execute("SELECT id FROM roles WHERE name = ?", (SystemRoles.ADMIN,))
admin_row = cursor.fetchone()
if not admin_row:
print("初始化失败:未找到 Admin 角色", file=sys.stderr)
return 1
admin_role_id = int(admin_row[0])
cursor.execute("DELETE FROM role_permissions WHERE role_id = ?", (admin_role_id,))
cursor.execute("SELECT id FROM permissions")
permission_ids = [int(row[0]) for row in cursor.fetchall()]
cursor.executemany(
"INSERT INTO role_permissions (role_id, permission_id) VALUES (?, ?)",
[(admin_role_id, pid) for pid in permission_ids],
)
# ensure Admin has full access path rule
cursor.execute(
"SELECT id FROM path_rules WHERE role_id = ? AND path_pattern = ? LIMIT 1",
(admin_role_id, "/**"),
)
existing_rule = cursor.fetchone()
if existing_rule:
cursor.execute(
"""
UPDATE path_rules
SET is_regex = 0,
can_read = 1,
can_write = 1,
can_delete = 1,
can_share = 1,
priority = 100
WHERE id = ?
""",
(int(existing_rule[0]),),
)
else:
cursor.execute(
"""
INSERT INTO path_rules (
role_id, path_pattern, is_regex,
can_read, can_write, can_delete, can_share,
priority
)
VALUES (?, ?, 0, 1, 1, 1, 1, 100)
""",
(admin_role_id, "/**"),
)
conn.commit()
finally:
conn.close()
print(f"已初始化权限: {len(PERMISSION_DEFINITIONS)}", file=sys.stderr)
print("已补齐内置角色: Admin / User / Viewer", file=sys.stderr)
print("已为 Admin 角色授予全部权限并设置 /** 全路径规则", file=sys.stderr)
return 0
def _build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(prog="foxel")
subparsers = parser.add_subparsers(dest="command", required=True)
@@ -139,6 +264,10 @@ def _build_parser() -> argparse.ArgumentParser:
reset_password.add_argument("--db", help="sqlite db 路径(默认 data/db/db.sqlite3")
reset_password.set_defaults(func=_cmd_reset_password)
init_rbac = subparsers.add_parser("init-rbac", help="初始化权限与内置角色")
init_rbac.add_argument("--db", help="sqlite db 路径(默认 data/db/db.sqlite3")
init_rbac.set_defaults(func=_cmd_init_rbac)
return parser
+1
View File
@@ -23,6 +23,7 @@ export interface MeResponse {
email?: string | null;
full_name?: string | null;
gravatar_url: string;
is_admin?: boolean;
}
export interface UpdateMePayload {
+27
View File
@@ -0,0 +1,27 @@
import request from './client';
import type { PathRuleInfo } from './roles';
export interface PermissionInfo {
id: number;
code: string;
name: string;
category: string;
description: string | null;
}
export interface UserPermissions {
user_id: number;
is_admin: boolean;
permissions: string[];
path_rules: PathRuleInfo[];
}
export const permissionsApi = {
listAll: async (): Promise<PermissionInfo[]> => {
return await request<PermissionInfo[]>('/permissions');
},
getMine: async (): Promise<UserPermissions> => {
return await request<UserPermissions>('/me/permissions');
},
};
+109
View File
@@ -0,0 +1,109 @@
import request from './client';
import type { UserInfo } from './users';
export interface RoleInfo {
id: number;
name: string;
description: string | null;
is_system: boolean;
created_at: string;
}
export interface RoleDetail extends RoleInfo {
permissions: string[];
path_rules_count: number;
}
export interface RoleCreate {
name: string;
description?: string | null;
}
export interface RoleUpdate {
name?: string | null;
description?: string | null;
}
export interface PathRuleInfo {
id: number;
role_id: number;
path_pattern: string;
is_regex: boolean;
can_read: boolean;
can_write: boolean;
can_delete: boolean;
can_share: boolean;
priority: number;
created_at: string;
}
export interface PathRuleCreate {
path_pattern: string;
is_regex?: boolean;
can_read?: boolean;
can_write?: boolean;
can_delete?: boolean;
can_share?: boolean;
priority?: number;
}
export const rolesApi = {
list: async (): Promise<RoleInfo[]> => {
return await request<RoleInfo[]>('/roles');
},
get: async (roleId: number): Promise<RoleDetail> => {
return await request<RoleDetail>(`/roles/${roleId}`);
},
getUsers: async (roleId: number): Promise<UserInfo[]> => {
return await request<UserInfo[]>(`/roles/${roleId}/users`);
},
create: async (data: RoleCreate): Promise<RoleInfo> => {
return await request<RoleInfo>('/roles', {
method: 'POST',
json: data,
});
},
update: async (roleId: number, data: RoleUpdate): Promise<RoleInfo> => {
return await request<RoleInfo>(`/roles/${roleId}`, {
method: 'PUT',
json: data,
});
},
remove: async (roleId: number): Promise<void> => {
await request(`/roles/${roleId}`, { method: 'DELETE' });
},
setPermissions: async (roleId: number, permissionCodes: string[]): Promise<string[]> => {
return await request<string[]>(`/roles/${roleId}/permissions`, {
method: 'POST',
json: { permission_codes: permissionCodes },
});
},
getPathRules: async (roleId: number): Promise<PathRuleInfo[]> => {
return await request<PathRuleInfo[]>(`/roles/${roleId}/path-rules`);
},
addPathRule: async (roleId: number, data: PathRuleCreate): Promise<PathRuleInfo> => {
return await request<PathRuleInfo>(`/roles/${roleId}/path-rules`, {
method: 'POST',
json: data,
});
},
updatePathRule: async (ruleId: number, data: PathRuleCreate): Promise<PathRuleInfo> => {
return await request<PathRuleInfo>(`/path-rules/${ruleId}`, {
method: 'PUT',
json: data,
});
},
deletePathRule: async (ruleId: number): Promise<void> => {
await request(`/path-rules/${ruleId}`, { method: 'DELETE' });
},
};
+77
View File
@@ -0,0 +1,77 @@
import request from './client';
export interface UserInfo {
id: number;
username: string;
email: string | null;
full_name: string | null;
disabled: boolean;
is_admin: boolean;
created_at: string;
last_login: string | null;
}
export interface UserDetail extends UserInfo {
roles: string[];
created_by_username: string | null;
}
export interface UserCreate {
username: string;
password: string;
email?: string | null;
full_name?: string | null;
is_admin?: boolean;
disabled?: boolean;
role_ids?: number[];
}
export interface UserUpdate {
email?: string | null;
full_name?: string | null;
password?: string | null;
is_admin?: boolean | null;
disabled?: boolean | null;
}
export const usersApi = {
list: async (): Promise<UserInfo[]> => {
return await request<UserInfo[]>('/users');
},
get: async (userId: number): Promise<UserDetail> => {
return await request<UserDetail>(`/users/${userId}`);
},
create: async (data: UserCreate): Promise<UserDetail> => {
return await request<UserDetail>('/users', {
method: 'POST',
json: data,
});
},
update: async (userId: number, data: UserUpdate): Promise<UserDetail> => {
return await request<UserDetail>(`/users/${userId}`, {
method: 'PUT',
json: data,
});
},
remove: async (userId: number): Promise<void> => {
await request(`/users/${userId}`, { method: 'DELETE' });
},
setRoles: async (userId: number, roleIds: number[]): Promise<string[]> => {
return await request<string[]>(`/users/${userId}/roles`, {
method: 'POST',
json: { role_ids: roleIds },
});
},
removeRole: async (userId: number, roleId: number): Promise<string[]> => {
return await request<string[]>(`/users/${userId}/roles/${roleId}`, {
method: 'DELETE',
});
},
};
+31 -1
View File
@@ -750,5 +750,35 @@
"Created": "Created",
"Moved": "Moved",
"Renamed": "Renamed",
"Info": "Info"
"Info": "Info",
"User Management": "User Management",
"Role Management": "Role Management",
"Users": "Users",
"Create User": "Create User",
"Create Role": "Create Role",
"Super Admin": "Super Admin",
"Disabled": "Disabled",
"Active": "Active",
"Search users or roles": "Search users or roles",
"Last Login": "Last Login",
"Roles": "Roles",
"Quick Create Role": "Quick Create Role",
"Select roles": "Select roles",
"Created by": "Created by",
"New Password (leave empty to keep current)": "New Password (leave empty to keep current)",
"Role Name": "Role Name",
"Path Rules": "Path Rules",
"Add Path Rule": "Add Path Rule",
"Edit Path Rule": "Edit Path Rule",
"Path Pattern": "Path Pattern",
"Is Regex": "Is Regex",
"Priority": "Priority",
"Higher value = higher priority": "Higher value = higher priority",
"System Permissions": "System Permissions",
"Download and preview files": "Download and preview files",
"Upload and modify files": "Upload and modify files",
"Delete files and folders": "Delete files and folders",
"Create share links": "Create share links",
"permission.category.system": "System",
"permission.category.adapter": "Adapter"
}
+35 -1
View File
@@ -750,5 +750,39 @@
"Created": "已创建",
"Moved": "已移动",
"Renamed": "已重命名",
"Info": "信息"
"Info": "信息",
"User Management": "用户管理",
"Role Management": "角色管理",
"Users": "用户",
"Create User": "创建用户",
"Create Role": "创建角色",
"Edit": "编辑",
"Super Admin": "超级管理员",
"Disabled": "已禁用",
"Active": "已启用",
"Search users or roles": "搜索用户或角色",
"Last Login": "上次登录",
"Roles": "角色",
"Quick Create Role": "快速创建角色",
"Select roles": "选择角色",
"Created by": "创建者",
"New Password (leave empty to keep current)": "新密码(留空则不修改)",
"Role Name": "角色名称",
"Path Rules": "路径规则",
"Add Path Rule": "添加路径规则",
"Edit Path Rule": "编辑路径规则",
"Path Pattern": "路径模式",
"Is Regex": "正则表达式",
"Priority": "优先级",
"Higher value = higher priority": "数值越大优先级越高",
"Permissions": "权限",
"System Permissions": "系统权限",
"Download and preview files": "下载和预览文件",
"Upload and modify files": "上传和修改文件",
"Delete files and folders": "删除文件和目录",
"Create share links": "创建分享链接",
"Share": "分享",
"Delete": "删除",
"permission.category.system": "系统",
"permission.category.adapter": "存储适配器"
}
+15 -2
View File
@@ -1,7 +1,7 @@
import { Layout, Menu, theme, Button, Modal, Tag, Tooltip, Descriptions, Alert, Divider, Spin } from 'antd';
import { navGroups } from './nav.ts';
import type { NavItem, NavGroup } from './nav.ts';
import { memo, useEffect, useState } from 'react';
import { memo, useEffect, useState, useMemo } from 'react';
import { useSystemStatus } from '../contexts/SystemContext.tsx';
import {
CheckCircleOutlined,
@@ -19,6 +19,7 @@ import { useTheme } from '../contexts/ThemeContext';
import { useI18n } from '../i18n';
import { useAppWindows } from '../contexts/AppWindowsContext';
import WeChatModal from '../components/WeChatModal';
import { useAuth } from '../contexts/AuthContext';
const { Sider } = Layout;
export interface SideNavProps {
@@ -33,12 +34,24 @@ const SideNav = memo(function SideNav({ collapsed, activeKey, onChange, onToggle
const { token } = theme.useToken();
const { resolvedMode } = useTheme();
const { t } = useI18n();
const { user } = useAuth();
const [isModalOpen, setIsModalOpen] = useState(false);
const [isVersionModalOpen, setIsVersionModalOpen] = useState(false);
const [latestVersion, setLatestVersion] = useState<{
version: string;
body: string;
} | null>(null);
// 根据用户权限过滤导航项
const filteredNavGroups = useMemo(() => {
const isAdmin = user?.is_admin ?? false;
return navGroups
.map(group => ({
...group,
children: group.children.filter(item => !item.adminOnly || isAdmin)
}))
.filter(group => group.children.length > 0);
}, [user]);
useEffect(() => {
getLatestVersion().then(resp => {
@@ -124,7 +137,7 @@ const SideNav = memo(function SideNav({ collapsed, activeKey, onChange, onToggle
</div>
{/* 分组渲染 */}
<div style={{ flex: 1, minHeight: 0, overflowY: 'auto', padding: '4px 4px 8px' }}>
{navGroups.map((group: NavGroup) => (
{filteredNavGroups.map((group: NavGroup) => (
<div key={group.key} style={{ marginBottom: 12 }}>
{group.title && (
<div
+3 -1
View File
@@ -11,10 +11,11 @@ import {
AppstoreOutlined,
CodeOutlined,
ClockCircleOutlined,
UserOutlined,
} from '@ant-design/icons';
import type { ReactNode } from 'react';
export interface NavItem { key: string; icon: ReactNode; label: string; }
export interface NavItem { key: string; icon: ReactNode; label: string; adminOnly?: boolean; }
export interface NavGroup { key: string; title?: string; children: NavItem[]; }
export const navGroups: NavGroup[] = [
@@ -42,6 +43,7 @@ export const navGroups: NavGroup[] = [
key: 'system',
title: 'System',
children: [
{ key: 'users', icon: React.createElement(UserOutlined), label: 'User Management', adminOnly: true },
{ key: 'settings', icon: React.createElement(SettingOutlined), label: 'System Settings' },
{ key: 'backup', icon: React.createElement(DatabaseOutlined), label: 'Backup & Restore' },
{ key: 'audit', icon: React.createElement(BugOutlined), label: 'Audit Logs' }
+435
View File
@@ -0,0 +1,435 @@
import { memo, useState, useEffect, useCallback } from 'react';
import {
Table, Button, Space, Drawer, Form, Input, Switch, message,
Tag, Popconfirm, Checkbox, Collapse, Typography, InputNumber, Divider
} from 'antd';
import { LockOutlined, FolderOutlined } from '@ant-design/icons';
import PageCard from '../../components/PageCard';
import { rolesApi, type PathRuleCreate, type PathRuleInfo, type RoleDetail, type RoleInfo } from '../../api/roles';
import { permissionsApi, type PermissionInfo } from '../../api/permissions';
import { useI18n } from '../../i18n';
const RolesPage = memo(function RolesPage() {
const [loading, setLoading] = useState(false);
const [roles, setRoles] = useState<RoleInfo[]>([]);
const [permissions, setPermissions] = useState<PermissionInfo[]>([]);
const [open, setOpen] = useState(false);
const [editing, setEditing] = useState<RoleDetail | null>(null);
const [pathRules, setPathRules] = useState<PathRuleInfo[]>([]);
const [form] = Form.useForm();
const [ruleForm] = Form.useForm();
const [ruleDrawerOpen, setRuleDrawerOpen] = useState(false);
const [editingRule, setEditingRule] = useState<PathRuleInfo | null>(null);
const { t } = useI18n();
const fetchData = useCallback(async () => {
setLoading(true);
try {
const [roleList, permList] = await Promise.all([
rolesApi.list(),
permissionsApi.listAll(),
]);
setRoles(roleList);
setPermissions(permList);
} catch (e: any) {
message.error(e.message || t('Load failed'));
} finally {
setLoading(false);
}
}, [t]);
useEffect(() => {
fetchData();
}, [fetchData]);
const openCreate = () => {
setEditing(null);
setPathRules([]);
form.resetFields();
form.setFieldsValue({
name: '',
description: '',
permissions: [],
});
setOpen(true);
};
const openEdit = async (rec: RoleInfo) => {
try {
setLoading(true);
const [detail, rules] = await Promise.all([
rolesApi.get(rec.id),
rolesApi.getPathRules(rec.id),
]);
setEditing(detail);
setPathRules(rules);
form.resetFields();
form.setFieldsValue({
name: detail.name,
description: detail.description || '',
permissions: detail.permissions,
});
setOpen(true);
} catch (e: any) {
message.error(e.message || t('Load failed'));
} finally {
setLoading(false);
}
};
const submit = async () => {
try {
const values = await form.validateFields();
setLoading(true);
if (editing) {
// 更新角色
await rolesApi.update(editing.id, {
name: values.name.trim(),
description: values.description || null,
});
// 更新权限
await rolesApi.setPermissions(editing.id, values.permissions || []);
message.success(t('Updated successfully'));
} else {
// 创建角色
const newRole = await rolesApi.create({
name: values.name.trim(),
description: values.description || null,
});
// 设置权限
if (values.permissions?.length) {
await rolesApi.setPermissions(newRole.id, values.permissions);
}
message.success(t('Created successfully'));
}
setOpen(false);
setEditing(null);
fetchData();
} catch (e: any) {
if (e?.errorFields) return;
message.error(e.message || t('Operation failed'));
} finally {
setLoading(false);
}
};
const doDelete = async (rec: RoleInfo) => {
try {
await rolesApi.remove(rec.id);
message.success(t('Deleted'));
fetchData();
} catch (e: any) {
message.error(e.message || t('Delete failed'));
}
};
// 路径规则管理
const openAddRule = () => {
setEditingRule(null);
ruleForm.resetFields();
ruleForm.setFieldsValue({
path_pattern: '/',
is_regex: false,
can_read: true,
can_write: false,
can_delete: false,
can_share: false,
priority: 0,
});
setRuleDrawerOpen(true);
};
const openEditRule = (rule: PathRuleInfo) => {
setEditingRule(rule);
ruleForm.resetFields();
ruleForm.setFieldsValue({
path_pattern: rule.path_pattern,
is_regex: rule.is_regex,
can_read: rule.can_read,
can_write: rule.can_write,
can_delete: rule.can_delete,
can_share: rule.can_share,
priority: rule.priority,
});
setRuleDrawerOpen(true);
};
const submitRule = async () => {
if (!editing) return;
try {
const values = await ruleForm.validateFields();
setLoading(true);
const ruleData: PathRuleCreate = {
path_pattern: values.path_pattern,
is_regex: values.is_regex,
can_read: values.can_read,
can_write: values.can_write,
can_delete: values.can_delete,
can_share: values.can_share,
priority: values.priority,
};
if (editingRule) {
await rolesApi.updatePathRule(editingRule.id, ruleData);
message.success(t('Updated successfully'));
} else {
await rolesApi.addPathRule(editing.id, ruleData);
message.success(t('Created successfully'));
}
// 刷新规则列表
const rules = await rolesApi.getPathRules(editing.id);
setPathRules(rules);
setRuleDrawerOpen(false);
setEditingRule(null);
} catch (e: any) {
if (e?.errorFields) return;
message.error(e.message || t('Operation failed'));
} finally {
setLoading(false);
}
};
const deleteRule = async (rule: PathRuleInfo) => {
if (!editing) return;
try {
await rolesApi.deletePathRule(rule.id);
message.success(t('Deleted'));
const rules = await rolesApi.getPathRules(editing.id);
setPathRules(rules);
} catch (e: any) {
message.error(e.message || t('Delete failed'));
}
};
// 按分类分组权限
const groupedPermissions = permissions.reduce((acc, p) => {
if (!acc[p.category]) acc[p.category] = [];
acc[p.category].push(p);
return acc;
}, {} as Record<string, PermissionInfo[]>);
const columns = [
{
title: t('Role Name'),
dataIndex: 'name',
render: (value: string, rec: RoleInfo) => (
<Space>
<LockOutlined />
{value}
{rec.is_system && <Tag color="blue">{t('System')}</Tag>}
</Space>
),
},
{ title: t('Description'), dataIndex: 'description', render: (v: string | null) => v || '-' },
{
title: t('Created At'),
dataIndex: 'created_at',
width: 180,
render: (v: string) => new Date(v).toLocaleString(),
},
{
title: t('Actions'),
width: 160,
render: (_: any, rec: RoleInfo) => (
<Space size="small">
<Button size="small" onClick={() => openEdit(rec)}>{t('Edit')}</Button>
{!rec.is_system && (
<Popconfirm title={t('Confirm delete?')} onConfirm={() => doDelete(rec)}>
<Button size="small" danger>{t('Delete')}</Button>
</Popconfirm>
)}
</Space>
),
},
];
const ruleColumns = [
{
title: t('Path Pattern'),
dataIndex: 'path_pattern',
render: (v: string, rec: PathRuleInfo) => (
<Space>
<FolderOutlined />
<code>{v}</code>
{rec.is_regex && <Tag color="purple">Regex</Tag>}
</Space>
),
},
{
title: t('Permissions'),
render: (_: any, rec: PathRuleInfo) => (
<Space size={[0, 4]} wrap>
{rec.can_read && <Tag color="green">{t('Read')}</Tag>}
{rec.can_write && <Tag color="blue">{t('Write')}</Tag>}
{rec.can_delete && <Tag color="red">{t('Delete')}</Tag>}
{rec.can_share && <Tag color="orange">{t('Share')}</Tag>}
</Space>
),
},
{
title: t('Priority'),
dataIndex: 'priority',
width: 80,
},
{
title: t('Actions'),
width: 140,
render: (_: any, rec: PathRuleInfo) => (
<Space size="small">
<Button size="small" onClick={() => openEditRule(rec)}>{t('Edit')}</Button>
<Popconfirm title={t('Confirm delete?')} onConfirm={() => deleteRule(rec)}>
<Button size="small" danger>{t('Delete')}</Button>
</Popconfirm>
</Space>
),
},
];
return (
<PageCard
title={t('Role Management')}
extra={
<Space>
<Button onClick={fetchData} loading={loading}>{t('Refresh')}</Button>
<Button type="primary" onClick={openCreate}>{t('Create Role')}</Button>
</Space>
}
>
<Table
rowKey="id"
dataSource={roles}
columns={columns as any}
loading={loading}
pagination={false}
style={{ marginBottom: 0 }}
/>
{/* 角色编辑抽屉 */}
<Drawer
title={editing ? `${t('Edit')}: ${editing.name}` : t('Create Role')}
width={600}
open={open}
onClose={() => { setOpen(false); setEditing(null); }}
destroyOnHidden
extra={
<Space>
<Button onClick={() => { setOpen(false); setEditing(null); }}>{t('Cancel')}</Button>
<Button type="primary" onClick={submit} loading={loading}>{t('Submit')}</Button>
</Space>
}
>
<Form form={form} layout="vertical">
<Form.Item
name="name"
label={t('Role Name')}
rules={[{ required: true, message: t('Please input {label}', { label: t('Role Name') }) }]}
>
<Input placeholder={t('Role Name')} disabled={editing?.is_system} />
</Form.Item>
<Form.Item name="description" label={t('Description')}>
<Input.TextArea placeholder={t('Description')} rows={2} />
</Form.Item>
<Divider>{t('System Permissions')}</Divider>
<Form.Item name="permissions" label={t('Permissions')}>
<Checkbox.Group style={{ width: '100%' }}>
<Collapse
items={Object.entries(groupedPermissions).map(([category, perms]) => ({
key: category,
label: t(`permission.category.${category}`) === `permission.category.${category}`
? category.charAt(0).toUpperCase() + category.slice(1)
: t(`permission.category.${category}`),
children: (
<Space direction="vertical" style={{ width: '100%' }}>
{perms.map(p => (
<Checkbox key={p.code} value={p.code}>
{p.name}
{p.description && (
<Typography.Text type="secondary" style={{ marginLeft: 8 }}>
{p.description}
</Typography.Text>
)}
</Checkbox>
))}
</Space>
),
}))}
/>
</Checkbox.Group>
</Form.Item>
{editing && (
<>
<Divider>{t('Path Rules')}</Divider>
<Space style={{ marginBottom: 16 }}>
<Button type="primary" size="small" onClick={openAddRule}>
{t('Add Path Rule')}
</Button>
</Space>
<Table
rowKey="id"
dataSource={pathRules}
columns={ruleColumns as any}
pagination={false}
size="small"
/>
</>
)}
</Form>
</Drawer>
{/* 路径规则编辑抽屉 */}
<Drawer
title={editingRule ? t('Edit Path Rule') : t('Add Path Rule')}
width={400}
open={ruleDrawerOpen}
onClose={() => { setRuleDrawerOpen(false); setEditingRule(null); }}
destroyOnHidden
extra={
<Space>
<Button onClick={() => { setRuleDrawerOpen(false); setEditingRule(null); }}>{t('Cancel')}</Button>
<Button type="primary" onClick={submitRule} loading={loading}>{t('Submit')}</Button>
</Space>
}
>
<Form form={ruleForm} layout="vertical">
<Form.Item
name="path_pattern"
label={t('Path Pattern')}
rules={[{ required: true, message: t('Please input {label}', { label: t('Path Pattern') }) }]}
extra={t('Use * for single level, ** for any level. Example: /photos/** matches all files in photos folder.')}
>
<Input placeholder="/photos/**" />
</Form.Item>
<Form.Item name="is_regex" label={t('Is Regex')} valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item name="priority" label={t('Priority')} extra={t('Higher value = higher priority')}>
<InputNumber style={{ width: '100%' }} />
</Form.Item>
<Divider>{t('Permissions')}</Divider>
<Space direction="vertical" style={{ width: '100%' }}>
<Form.Item name="can_read" valuePropName="checked" noStyle>
<Checkbox>{t('Read')} - {t('Download and preview files')}</Checkbox>
</Form.Item>
<Form.Item name="can_write" valuePropName="checked" noStyle>
<Checkbox>{t('Write')} - {t('Upload and modify files')}</Checkbox>
</Form.Item>
<Form.Item name="can_delete" valuePropName="checked" noStyle>
<Checkbox>{t('Delete')} - {t('Delete files and folders')}</Checkbox>
</Form.Item>
<Form.Item name="can_share" valuePropName="checked" noStyle>
<Checkbox>{t('Share')} - {t('Create share links')}</Checkbox>
</Form.Item>
</Space>
</Form>
</Drawer>
</PageCard>
);
});
export default RolesPage;
+863
View File
@@ -0,0 +1,863 @@
import { memo, useCallback, useEffect, useMemo, useState } from 'react';
import {
Button,
Checkbox,
Collapse,
Divider,
Drawer,
Form,
Input,
InputNumber,
Modal,
Popconfirm,
Select,
Space,
Switch,
Table,
Tabs,
Tag,
Typography,
message,
} from 'antd';
import {
CrownOutlined,
FolderOutlined,
LockOutlined,
UserOutlined,
} from '@ant-design/icons';
import PageCard from '../../components/PageCard';
import { usersApi, type UserDetail, type UserInfo } from '../../api/users';
import {
rolesApi,
type PathRuleCreate,
type PathRuleInfo,
type RoleDetail,
type RoleInfo,
} from '../../api/roles';
import { permissionsApi, type PermissionInfo } from '../../api/permissions';
import { useI18n } from '../../i18n';
type TabKey = 'users' | 'roles';
const UsersPage = memo(function UsersPage() {
const { t } = useI18n();
const [loading, setLoading] = useState(false);
const [activeTab, setActiveTab] = useState<TabKey>('users');
const [searchText, setSearchText] = useState('');
const [users, setUsers] = useState<UserInfo[]>([]);
const [roles, setRoles] = useState<RoleInfo[]>([]);
const [permissions, setPermissions] = useState<PermissionInfo[]>([]);
const [userDrawerOpen, setUserDrawerOpen] = useState(false);
const [editingUser, setEditingUser] = useState<UserDetail | null>(null);
const [userForm] = Form.useForm();
const [roleDrawerOpen, setRoleDrawerOpen] = useState(false);
const [editingRole, setEditingRole] = useState<RoleDetail | null>(null);
const [pathRules, setPathRules] = useState<PathRuleInfo[]>([]);
const [roleUsers, setRoleUsers] = useState<UserInfo[]>([]);
const [roleForm] = Form.useForm();
const [ruleDrawerOpen, setRuleDrawerOpen] = useState(false);
const [editingRule, setEditingRule] = useState<PathRuleInfo | null>(null);
const [ruleForm] = Form.useForm();
const [quickRoleModalOpen, setQuickRoleModalOpen] = useState(false);
const [quickRoleForm] = Form.useForm();
const fetchData = useCallback(async () => {
setLoading(true);
try {
const [userList, roleList, permList] = await Promise.all([
usersApi.list(),
rolesApi.list(),
permissionsApi.listAll(),
]);
setUsers(userList);
setRoles(roleList);
setPermissions(permList);
} catch (e: any) {
message.error(e.message || t('Load failed'));
} finally {
setLoading(false);
}
}, [t]);
useEffect(() => {
fetchData();
}, [fetchData]);
const normalizedSearch = searchText.trim().toLowerCase();
const filteredUsers = useMemo(() => {
if (!normalizedSearch) return users;
return users.filter((u) => {
const haystacks = [
u.username,
u.email ?? '',
u.full_name ?? '',
].map(v => v.toLowerCase());
return haystacks.some(v => v.includes(normalizedSearch));
});
}, [normalizedSearch, users]);
const filteredRoles = useMemo(() => {
if (!normalizedSearch) return roles;
return roles.filter((r) => {
const haystacks = [
r.name,
r.description ?? '',
].map(v => v.toLowerCase());
return haystacks.some(v => v.includes(normalizedSearch));
});
}, [normalizedSearch, roles]);
const groupedPermissions = useMemo(() => {
return permissions.reduce((acc, p) => {
if (!acc[p.category]) acc[p.category] = [];
acc[p.category].push(p);
return acc;
}, {} as Record<string, PermissionInfo[]>);
}, [permissions]);
// --- User ops ---
const openCreateUser = () => {
setEditingUser(null);
userForm.resetFields();
userForm.setFieldsValue({
username: '',
password: '',
email: '',
full_name: '',
is_admin: false,
disabled: false,
role_ids: [],
});
setUserDrawerOpen(true);
};
const openEditUser = async (rec: UserInfo) => {
try {
setLoading(true);
const detail = await usersApi.get(rec.id);
setEditingUser(detail);
userForm.resetFields();
const roleIds = roles
.filter(r => detail.roles.includes(r.name))
.map(r => r.id);
userForm.setFieldsValue({
username: detail.username,
password: '',
email: detail.email || '',
full_name: detail.full_name || '',
is_admin: detail.is_admin,
disabled: detail.disabled,
role_ids: roleIds,
});
setUserDrawerOpen(true);
} catch (e: any) {
message.error(e.message || t('Load failed'));
} finally {
setLoading(false);
}
};
const submitUser = async () => {
try {
const values = await userForm.validateFields();
setLoading(true);
if (editingUser) {
const updateData: any = {
email: values.email || null,
full_name: values.full_name || null,
is_admin: values.is_admin,
disabled: values.disabled,
};
if (values.password) updateData.password = values.password;
await usersApi.update(editingUser.id, updateData);
await usersApi.setRoles(editingUser.id, values.role_ids || []);
message.success(t('Updated successfully'));
} else {
await usersApi.create({
username: values.username.trim(),
password: values.password,
email: values.email || null,
full_name: values.full_name || null,
is_admin: values.is_admin,
disabled: values.disabled,
role_ids: values.role_ids || [],
});
message.success(t('Created successfully'));
}
setUserDrawerOpen(false);
setEditingUser(null);
await fetchData();
if (editingRole) {
const nextRoleUsers = await rolesApi.getUsers(editingRole.id);
setRoleUsers(nextRoleUsers);
}
} catch (e: any) {
if (e?.errorFields) return;
message.error(e.message || t('Operation failed'));
} finally {
setLoading(false);
}
};
const doDeleteUser = async (rec: UserInfo) => {
try {
await usersApi.remove(rec.id);
message.success(t('Deleted'));
fetchData();
} catch (e: any) {
message.error(e.message || t('Delete failed'));
}
};
const handleToggleDisabled = async (rec: UserInfo, disabled: boolean) => {
try {
setLoading(true);
await usersApi.update(rec.id, { disabled });
message.success(t('Status updated'));
fetchData();
} catch (e: any) {
message.error(e.message || t('Update failed'));
} finally {
setLoading(false);
}
};
// --- Quick create role (for user drawer) ---
const openQuickCreateRole = () => {
quickRoleForm.resetFields();
quickRoleForm.setFieldsValue({
name: '',
description: '',
});
setQuickRoleModalOpen(true);
};
const submitQuickRole = async () => {
try {
const values = await quickRoleForm.validateFields();
setLoading(true);
const newRole = await rolesApi.create({
name: values.name.trim(),
description: values.description || null,
});
message.success(t('Created successfully'));
setRoles((prev) => [...prev, newRole].sort((a, b) => a.id - b.id));
const currentIds = (userForm.getFieldValue('role_ids') || []) as number[];
const nextIds = Array.from(new Set([...currentIds, newRole.id]));
userForm.setFieldsValue({ role_ids: nextIds });
setQuickRoleModalOpen(false);
} catch (e: any) {
if (e?.errorFields) return;
message.error(e.message || t('Operation failed'));
} finally {
setLoading(false);
}
};
// --- Role ops ---
const openCreateRole = () => {
setEditingRole(null);
setPathRules([]);
setRoleUsers([]);
roleForm.resetFields();
roleForm.setFieldsValue({
name: '',
description: '',
permissions: [],
});
setRoleDrawerOpen(true);
};
const openEditRole = async (rec: RoleInfo) => {
try {
setLoading(true);
const [detail, rules, usersUsingRole] = await Promise.all([
rolesApi.get(rec.id),
rolesApi.getPathRules(rec.id),
rolesApi.getUsers(rec.id),
]);
setEditingRole(detail);
setPathRules(rules);
setRoleUsers(usersUsingRole);
roleForm.resetFields();
roleForm.setFieldsValue({
name: detail.name,
description: detail.description || '',
permissions: detail.permissions,
});
setRoleDrawerOpen(true);
} catch (e: any) {
message.error(e.message || t('Load failed'));
} finally {
setLoading(false);
}
};
const submitRole = async () => {
try {
const values = await roleForm.validateFields();
setLoading(true);
if (editingRole) {
await rolesApi.update(editingRole.id, {
name: values.name.trim(),
description: values.description || null,
});
await rolesApi.setPermissions(editingRole.id, values.permissions || []);
message.success(t('Updated successfully'));
} else {
const newRole = await rolesApi.create({
name: values.name.trim(),
description: values.description || null,
});
if (values.permissions?.length) {
await rolesApi.setPermissions(newRole.id, values.permissions);
}
message.success(t('Created successfully'));
}
setRoleDrawerOpen(false);
setEditingRole(null);
await fetchData();
} catch (e: any) {
if (e?.errorFields) return;
message.error(e.message || t('Operation failed'));
} finally {
setLoading(false);
}
};
const doDeleteRole = async (rec: RoleInfo) => {
try {
await rolesApi.remove(rec.id);
message.success(t('Deleted'));
fetchData();
} catch (e: any) {
message.error(e.message || t('Delete failed'));
}
};
// --- Path rules ---
const openAddRule = () => {
setEditingRule(null);
ruleForm.resetFields();
ruleForm.setFieldsValue({
path_pattern: '/',
is_regex: false,
can_read: true,
can_write: false,
can_delete: false,
can_share: false,
priority: 0,
});
setRuleDrawerOpen(true);
};
const openEditRule = (rule: PathRuleInfo) => {
setEditingRule(rule);
ruleForm.resetFields();
ruleForm.setFieldsValue({
path_pattern: rule.path_pattern,
is_regex: rule.is_regex,
can_read: rule.can_read,
can_write: rule.can_write,
can_delete: rule.can_delete,
can_share: rule.can_share,
priority: rule.priority,
});
setRuleDrawerOpen(true);
};
const submitRule = async () => {
if (!editingRole) return;
try {
const values = await ruleForm.validateFields();
setLoading(true);
const ruleData: PathRuleCreate = {
path_pattern: values.path_pattern,
is_regex: values.is_regex,
can_read: values.can_read,
can_write: values.can_write,
can_delete: values.can_delete,
can_share: values.can_share,
priority: values.priority,
};
if (editingRule) {
await rolesApi.updatePathRule(editingRule.id, ruleData);
message.success(t('Updated successfully'));
} else {
await rolesApi.addPathRule(editingRole.id, ruleData);
message.success(t('Created successfully'));
}
const rules = await rolesApi.getPathRules(editingRole.id);
setPathRules(rules);
setRuleDrawerOpen(false);
setEditingRule(null);
} catch (e: any) {
if (e?.errorFields) return;
message.error(e.message || t('Operation failed'));
} finally {
setLoading(false);
}
};
const deleteRule = async (rule: PathRuleInfo) => {
if (!editingRole) return;
try {
await rolesApi.deletePathRule(rule.id);
message.success(t('Deleted'));
const rules = await rolesApi.getPathRules(editingRole.id);
setPathRules(rules);
} catch (e: any) {
message.error(e.message || t('Delete failed'));
}
};
const userColumns = [
{
title: t('Username'),
dataIndex: 'username',
render: (value: string, rec: UserInfo) => (
<Space>
{rec.is_admin ? <CrownOutlined style={{ color: '#faad14' }} /> : <UserOutlined />}
{value}
{rec.is_admin && <Tag color="gold">{t('Admin')}</Tag>}
</Space>
),
},
{ title: t('Email'), dataIndex: 'email', render: (v: string | null) => v || '-' },
{ title: t('Full Name'), dataIndex: 'full_name', render: (v: string | null) => v || '-' },
{
title: t('Status'),
dataIndex: 'disabled',
width: 100,
render: (disabled: boolean, rec: UserInfo) => (
<Switch
checked={!disabled}
size="small"
loading={loading}
onChange={(checked) => handleToggleDisabled(rec, !checked)}
checkedChildren={t('Active')}
unCheckedChildren={t('Disabled')}
/>
),
},
{
title: t('Last Login'),
dataIndex: 'last_login',
width: 180,
render: (v: string | null) => v ? new Date(v).toLocaleString() : '-',
},
{
title: t('Actions'),
width: 160,
render: (_: any, rec: UserInfo) => (
<Space size="small">
<Button size="small" onClick={() => openEditUser(rec)}>{t('Edit')}</Button>
<Popconfirm title={t('Confirm delete?')} onConfirm={() => doDeleteUser(rec)}>
<Button size="small" danger>{t('Delete')}</Button>
</Popconfirm>
</Space>
),
},
];
const roleColumns = [
{
title: t('Role Name'),
dataIndex: 'name',
render: (value: string, rec: RoleInfo) => (
<Space>
<LockOutlined />
{value}
{rec.is_system && <Tag color="blue">{t('System')}</Tag>}
</Space>
),
},
{ title: t('Description'), dataIndex: 'description', render: (v: string | null) => v || '-' },
{
title: t('Created At'),
dataIndex: 'created_at',
width: 180,
render: (v: string) => new Date(v).toLocaleString(),
},
{
title: t('Actions'),
width: 160,
render: (_: any, rec: RoleInfo) => (
<Space size="small">
<Button size="small" onClick={() => openEditRole(rec)}>{t('Edit')}</Button>
{!rec.is_system && (
<Popconfirm title={t('Confirm delete?')} onConfirm={() => doDeleteRole(rec)}>
<Button size="small" danger>{t('Delete')}</Button>
</Popconfirm>
)}
</Space>
),
},
];
const ruleColumns = [
{
title: t('Path Pattern'),
dataIndex: 'path_pattern',
render: (v: string, rec: PathRuleInfo) => (
<Space>
<FolderOutlined />
<code>{v}</code>
{rec.is_regex && <Tag color="purple">Regex</Tag>}
</Space>
),
},
{
title: t('Permissions'),
render: (_: any, rec: PathRuleInfo) => (
<Space size={[0, 4]} wrap>
{rec.can_read && <Tag color="green">{t('Read')}</Tag>}
{rec.can_write && <Tag color="blue">{t('Write')}</Tag>}
{rec.can_delete && <Tag color="red">{t('Delete')}</Tag>}
{rec.can_share && <Tag color="orange">{t('Share')}</Tag>}
</Space>
),
},
{
title: t('Priority'),
dataIndex: 'priority',
width: 80,
},
{
title: t('Actions'),
width: 140,
render: (_: any, rec: PathRuleInfo) => (
<Space size="small">
<Button size="small" onClick={() => openEditRule(rec)}>{t('Edit')}</Button>
<Popconfirm title={t('Confirm delete?')} onConfirm={() => deleteRule(rec)}>
<Button size="small" danger>{t('Delete')}</Button>
</Popconfirm>
</Space>
),
},
];
const roleUserColumns = [
{
title: t('Username'),
dataIndex: 'username',
render: (value: string, rec: UserInfo) => (
<Space>
{rec.is_admin ? <CrownOutlined style={{ color: '#faad14' }} /> : <UserOutlined />}
{value}
{rec.is_admin && <Tag color="gold">{t('Admin')}</Tag>}
</Space>
),
},
{ title: t('Email'), dataIndex: 'email', render: (v: string | null) => v || '-' },
{
title: t('Status'),
dataIndex: 'disabled',
width: 90,
render: (disabled: boolean) => disabled ? <Tag>{t('Disabled')}</Tag> : <Tag color="green">{t('Active')}</Tag>,
},
{
title: t('Actions'),
width: 110,
render: (_: any, rec: UserInfo) => (
<Button size="small" onClick={() => openEditUser(rec)}>{t('Edit')}</Button>
),
},
];
const tabItems = [
{
key: 'users',
label: `${t('Users')} (${filteredUsers.length})`,
children: (
<Table
rowKey="id"
dataSource={filteredUsers}
columns={userColumns as any}
loading={loading}
pagination={false}
style={{ marginBottom: 0 }}
/>
),
},
{
key: 'roles',
label: `${t('Roles')} (${filteredRoles.length})`,
children: (
<Table
rowKey="id"
dataSource={filteredRoles}
columns={roleColumns as any}
loading={loading}
pagination={false}
style={{ marginBottom: 0 }}
/>
),
},
];
return (
<PageCard
title={t('User Management')}
extra={
<Space>
<Input.Search
allowClear
value={searchText}
placeholder={t('Search users or roles')}
onChange={(e) => setSearchText(e.target.value)}
style={{ width: 260 }}
/>
<Button onClick={fetchData} loading={loading}>{t('Refresh')}</Button>
<Button type="primary" onClick={() => { setActiveTab('users'); openCreateUser(); }}>
{t('Create User')}
</Button>
<Button onClick={() => { setActiveTab('roles'); openCreateRole(); }}>
{t('Create Role')}
</Button>
</Space>
}
>
<Tabs activeKey={activeTab} onChange={(k) => setActiveTab(k as TabKey)} items={tabItems} />
{/* User editor */}
<Drawer
title={editingUser ? `${t('Edit')}: ${editingUser.username}` : t('Create User')}
width={480}
open={userDrawerOpen}
onClose={() => { setUserDrawerOpen(false); setEditingUser(null); }}
destroyOnHidden
extra={
<Space>
<Button onClick={() => { setUserDrawerOpen(false); setEditingUser(null); }}>{t('Cancel')}</Button>
<Button type="primary" onClick={submitUser} loading={loading}>{t('Submit')}</Button>
</Space>
}
>
<Form form={userForm} layout="vertical">
<Form.Item
name="username"
label={t('Username')}
rules={[{ required: true, message: t('Please input {label}', { label: t('Username') }) }]}
>
<Input placeholder={t('Username')} disabled={!!editingUser} />
</Form.Item>
<Form.Item
name="password"
label={editingUser ? t('New Password (leave empty to keep current)') : t('Password')}
rules={editingUser ? [] : [{ required: true, message: t('Please input {label}', { label: t('Password') }) }]}
>
<Input.Password placeholder={t('Password')} />
</Form.Item>
<Form.Item name="email" label={t('Email')}>
<Input placeholder={t('Email')} />
</Form.Item>
<Form.Item name="full_name" label={t('Full Name')}>
<Input placeholder={t('Full Name')} />
</Form.Item>
<Form.Item
name="role_ids"
label={(
<Space size={4}>
{t('Roles')}
<Button type="link" size="small" onClick={openQuickCreateRole}>
{t('Quick Create Role')}
</Button>
</Space>
)}
>
<Select
mode="multiple"
placeholder={t('Select roles')}
options={roles.map(r => ({
value: r.id,
label: r.name + (r.is_system ? ` (${t('System')})` : ''),
}))}
/>
</Form.Item>
<Form.Item name="is_admin" label={t('Super Admin')} valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item name="disabled" label={t('Disabled')} valuePropName="checked">
<Switch />
</Form.Item>
{editingUser && (
<Typography.Text type="secondary">
{t('Created by')}: {editingUser.created_by_username || '-'}
</Typography.Text>
)}
</Form>
</Drawer>
<Modal
title={t('Create Role')}
open={quickRoleModalOpen}
onCancel={() => setQuickRoleModalOpen(false)}
okText={t('Submit')}
cancelText={t('Cancel')}
confirmLoading={loading}
onOk={submitQuickRole}
destroyOnHidden
>
<Form form={quickRoleForm} layout="vertical">
<Form.Item
name="name"
label={t('Role Name')}
rules={[{ required: true, message: t('Please input {label}', { label: t('Role Name') }) }]}
>
<Input placeholder={t('Role Name')} />
</Form.Item>
<Form.Item name="description" label={t('Description')}>
<Input.TextArea placeholder={t('Description')} rows={2} />
</Form.Item>
</Form>
</Modal>
{/* Role editor */}
<Drawer
title={editingRole ? `${t('Edit')}: ${editingRole.name}` : t('Create Role')}
width={600}
open={roleDrawerOpen}
onClose={() => { setRoleDrawerOpen(false); setEditingRole(null); }}
destroyOnHidden
extra={
<Space>
<Button onClick={() => { setRoleDrawerOpen(false); setEditingRole(null); }}>{t('Cancel')}</Button>
<Button type="primary" onClick={submitRole} loading={loading}>{t('Submit')}</Button>
</Space>
}
>
<Form form={roleForm} layout="vertical">
<Form.Item
name="name"
label={t('Role Name')}
rules={[{ required: true, message: t('Please input {label}', { label: t('Role Name') }) }]}
>
<Input placeholder={t('Role Name')} disabled={editingRole?.is_system} />
</Form.Item>
<Form.Item name="description" label={t('Description')}>
<Input.TextArea placeholder={t('Description')} rows={2} />
</Form.Item>
<Divider>{t('System Permissions')}</Divider>
<Form.Item name="permissions" label={t('Permissions')}>
<Checkbox.Group style={{ width: '100%' }}>
<Collapse
items={Object.entries(groupedPermissions).map(([category, perms]) => ({
key: category,
label: t(`permission.category.${category}`) === `permission.category.${category}`
? category.charAt(0).toUpperCase() + category.slice(1)
: t(`permission.category.${category}`),
children: (
<Space direction="vertical" style={{ width: '100%' }}>
{perms.map(p => (
<Checkbox key={p.code} value={p.code}>
{p.name}
{p.description && (
<Typography.Text type="secondary" style={{ marginLeft: 8 }}>
{p.description}
</Typography.Text>
)}
</Checkbox>
))}
</Space>
),
}))}
/>
</Checkbox.Group>
</Form.Item>
{editingRole && (
<>
<Divider>{t('Path Rules')}</Divider>
<Space style={{ marginBottom: 16 }}>
<Button type="primary" size="small" onClick={openAddRule}>
{t('Add Path Rule')}
</Button>
</Space>
<Table
rowKey="id"
dataSource={pathRules}
columns={ruleColumns as any}
pagination={false}
size="small"
/>
<Divider>{t('Users')}</Divider>
<Table
rowKey="id"
dataSource={roleUsers}
columns={roleUserColumns as any}
pagination={false}
size="small"
/>
</>
)}
</Form>
</Drawer>
{/* Path rule editor */}
<Drawer
title={editingRule ? t('Edit Path Rule') : t('Add Path Rule')}
width={400}
open={ruleDrawerOpen}
onClose={() => { setRuleDrawerOpen(false); setEditingRule(null); }}
destroyOnHidden
extra={
<Space>
<Button onClick={() => { setRuleDrawerOpen(false); setEditingRule(null); }}>{t('Cancel')}</Button>
<Button type="primary" onClick={submitRule} loading={loading}>{t('Submit')}</Button>
</Space>
}
>
<Form form={ruleForm} layout="vertical">
<Form.Item
name="path_pattern"
label={t('Path Pattern')}
rules={[{ required: true, message: t('Please input {label}', { label: t('Path Pattern') }) }]}
extra={t('Use * for single level, ** for any level. Example: /photos/** matches all files in photos folder.')}
>
<Input placeholder="/photos/**" />
</Form.Item>
<Form.Item name="is_regex" label={t('Is Regex')} valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item name="priority" label={t('Priority')} extra={t('Higher value = higher priority')}>
<InputNumber style={{ width: '100%' }} />
</Form.Item>
<Divider>{t('Permissions')}</Divider>
<Space direction="vertical" style={{ width: '100%' }}>
<Form.Item name="can_read" valuePropName="checked" noStyle>
<Checkbox>{t('Read')} - {t('Download and preview files')}</Checkbox>
</Form.Item>
<Form.Item name="can_write" valuePropName="checked" noStyle>
<Checkbox>{t('Write')} - {t('Upload and modify files')}</Checkbox>
</Form.Item>
<Form.Item name="can_delete" valuePropName="checked" noStyle>
<Checkbox>{t('Delete')} - {t('Delete files and folders')}</Checkbox>
</Form.Item>
<Form.Item name="can_share" valuePropName="checked" noStyle>
<Checkbox>{t('Share')} - {t('Create share links')}</Checkbox>
</Form.Item>
</Space>
</Form>
</Drawer>
</PageCard>
);
});
export default UsersPage;
+2
View File
@@ -14,6 +14,7 @@ import SystemSettingsPage from '../pages/SystemSettingsPage/SystemSettingsPage.t
import AuditLogsPage from '../pages/AuditLogsPage.tsx';
import BackupPage from '../pages/SystemSettingsPage/BackupPage.tsx';
import PluginsPage from '../pages/PluginsPage.tsx';
import UsersPage from '../pages/AdminPage/UsersPage.tsx';
import { AppWindowsProvider, useAppWindows } from '../contexts/AppWindowsContext';
import { AppWindowsLayer } from '../apps/AppWindowsLayer';
import AiAgentWidget from '../components/AiAgentWidget';
@@ -67,6 +68,7 @@ const ShellBody = memo(function ShellBody() {
)}
{navKey === 'audit' && <AuditLogsPage />}
{navKey === 'backup' && <BackupPage />}
{navKey === 'users' && <UsersPage />}
</Flex>
</div>
</Layout.Content>