mirror of
https://github.com/DrizzleTime/Foxel.git
synced 2026-09-05 07:36:53 +08:00
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:
@@ -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.mapping import s3_api, webdav_api
|
||||||
from domain.virtual_fs.search import search_api
|
from domain.virtual_fs.search import search_api
|
||||||
from domain.audit import api as audit
|
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):
|
def include_routers(app: FastAPI):
|
||||||
@@ -38,3 +41,6 @@ def include_routers(app: FastAPI):
|
|||||||
app.include_router(offline_downloads.router)
|
app.include_router(offline_downloads.router)
|
||||||
app.include_router(email.router)
|
app.include_router(email.router)
|
||||||
app.include_router(audit.router)
|
app.include_router(audit.router)
|
||||||
|
app.include_router(permission.router)
|
||||||
|
app.include_router(user.router)
|
||||||
|
app.include_router(role.router)
|
||||||
|
|||||||
@@ -140,6 +140,7 @@ class AuthService:
|
|||||||
email=user.email,
|
email=user.email,
|
||||||
full_name=user.full_name,
|
full_name=user.full_name,
|
||||||
disabled=user.disabled,
|
disabled=user.disabled,
|
||||||
|
is_admin=user.is_admin,
|
||||||
hashed_password=user.hashed_password,
|
hashed_password=user.hashed_password,
|
||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
@@ -166,12 +167,14 @@ class AuthService:
|
|||||||
if exists:
|
if exists:
|
||||||
raise HTTPException(status_code=400, detail="用户名已存在")
|
raise HTTPException(status_code=400, detail="用户名已存在")
|
||||||
hashed = cls.get_password_hash(payload.password)
|
hashed = cls.get_password_hash(payload.password)
|
||||||
|
# 第一个用户自动成为超级管理员
|
||||||
user = await UserAccount.create(
|
user = await UserAccount.create(
|
||||||
username=payload.username,
|
username=payload.username,
|
||||||
email=payload.email,
|
email=payload.email,
|
||||||
full_name=payload.full_name,
|
full_name=payload.full_name,
|
||||||
hashed_password=hashed,
|
hashed_password=hashed,
|
||||||
disabled=False,
|
disabled=False,
|
||||||
|
is_admin=True, # 第一个用户是超级管理员
|
||||||
)
|
)
|
||||||
return user
|
return user
|
||||||
|
|
||||||
@@ -195,6 +198,13 @@ class AuthService:
|
|||||||
detail="用户名或密码错误",
|
detail="用户名或密码错误",
|
||||||
headers={"WWW-Authenticate": "Bearer"},
|
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_expires = timedelta(minutes=cls.access_token_expire_minutes)
|
||||||
access_token = await cls.create_access_token(
|
access_token = await cls.create_access_token(
|
||||||
data={"sub": user.username}, expires_delta=access_token_expires
|
data={"sub": user.username}, expires_delta=access_token_expires
|
||||||
@@ -212,6 +222,7 @@ class AuthService:
|
|||||||
"email": getattr(user, "email", None),
|
"email": getattr(user, "email", None),
|
||||||
"full_name": getattr(user, "full_name", None),
|
"full_name": getattr(user, "full_name", None),
|
||||||
"gravatar_url": gravatar_url,
|
"gravatar_url": gravatar_url,
|
||||||
|
"is_admin": getattr(user, "is_admin", False),
|
||||||
}
|
}
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ class User(BaseModel):
|
|||||||
email: str | None = None
|
email: str | None = None
|
||||||
full_name: str | None = None
|
full_name: str | None = None
|
||||||
disabled: bool | None = None
|
disabled: bool | None = None
|
||||||
|
is_admin: bool = False
|
||||||
|
|
||||||
|
|
||||||
class UserInDB(User):
|
class UserInDB(User):
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
from .service import PermissionService
|
||||||
|
from .matcher import PathMatcher
|
||||||
|
|
||||||
|
__all__ = ["PermissionService", "PathMatcher"]
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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] # 路径权限规则
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from .service import RoleService
|
||||||
|
|
||||||
|
__all__ = ["RoleService"]
|
||||||
@@ -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}
|
||||||
|
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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"
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
from .service import UserService
|
||||||
|
|
||||||
|
__all__ = ["UserService"]
|
||||||
|
|
||||||
@@ -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)
|
||||||
|
|
||||||
@@ -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]
|
||||||
|
|
||||||
@@ -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]
|
||||||
|
|
||||||
@@ -5,6 +5,8 @@ from fastapi import APIRouter, Depends, File, Query, Request, UploadFile
|
|||||||
from api.response import success
|
from api.response import success
|
||||||
from domain.audit import AuditAction, audit
|
from domain.audit import AuditAction, audit
|
||||||
from domain.auth import User, get_current_active_user
|
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 .service import VirtualFSService
|
||||||
from .types import MkdirRequest, MoveRequest
|
from .types import MkdirRequest, MoveRequest
|
||||||
|
|
||||||
@@ -18,6 +20,7 @@ async def get_file(
|
|||||||
request: Request,
|
request: Request,
|
||||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
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"))
|
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)],
|
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||||
expires_in: int = Query(3600, description="有效时间(秒), 0或负数表示永久"),
|
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)
|
data = await VirtualFSService.create_temp_link(full_path, expires_in)
|
||||||
return success(data)
|
return success(data)
|
||||||
|
|
||||||
@@ -80,6 +84,7 @@ async def get_file_stat(
|
|||||||
request: Request,
|
request: Request,
|
||||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
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)
|
stat = await VirtualFSService.stat(full_path)
|
||||||
return success(stat)
|
return success(stat)
|
||||||
|
|
||||||
@@ -92,6 +97,7 @@ async def put_file(
|
|||||||
full_path: str,
|
full_path: str,
|
||||||
file: UploadFile = File(...),
|
file: UploadFile = File(...),
|
||||||
):
|
):
|
||||||
|
await PermissionService.require_path_permission(current_user.id, full_path, PathAction.WRITE)
|
||||||
data = await file.read()
|
data = await file.read()
|
||||||
result = await VirtualFSService.write_uploaded_file(full_path, data)
|
result = await VirtualFSService.write_uploaded_file(full_path, data)
|
||||||
return success(result)
|
return success(result)
|
||||||
@@ -104,6 +110,7 @@ async def api_mkdir(
|
|||||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||||
body: MkdirRequest,
|
body: MkdirRequest,
|
||||||
):
|
):
|
||||||
|
await PermissionService.require_path_permission(current_user.id, body.path, PathAction.WRITE)
|
||||||
result = await VirtualFSService.mkdir(body.path)
|
result = await VirtualFSService.mkdir(body.path)
|
||||||
return success(result)
|
return success(result)
|
||||||
|
|
||||||
@@ -116,6 +123,9 @@ async def api_move(
|
|||||||
body: MoveRequest,
|
body: MoveRequest,
|
||||||
overwrite: bool = Query(False, description="是否允许覆盖已存在目标"),
|
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)
|
result = await VirtualFSService.move(body.src, body.dst, overwrite)
|
||||||
return success(result)
|
return success(result)
|
||||||
|
|
||||||
@@ -128,6 +138,8 @@ async def api_rename(
|
|||||||
body: MoveRequest,
|
body: MoveRequest,
|
||||||
overwrite: bool = Query(False, description="是否允许覆盖已存在目标"),
|
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)
|
result = await VirtualFSService.rename(body.src, body.dst, overwrite)
|
||||||
return success(result)
|
return success(result)
|
||||||
|
|
||||||
@@ -140,6 +152,9 @@ async def api_copy(
|
|||||||
body: MoveRequest,
|
body: MoveRequest,
|
||||||
overwrite: bool = Query(False, description="是否覆盖已存在目标"),
|
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)
|
result = await VirtualFSService.copy(body.src, body.dst, overwrite)
|
||||||
return success(result)
|
return success(result)
|
||||||
|
|
||||||
@@ -154,6 +169,7 @@ async def upload_stream(
|
|||||||
overwrite: bool = Query(True, description="是否覆盖已存在文件"),
|
overwrite: bool = Query(True, description="是否覆盖已存在文件"),
|
||||||
chunk_size: int = Query(1024 * 1024, ge=8 * 1024, le=8 * 1024 * 1024, 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)
|
result = await VirtualFSService.upload_stream_from_upload_file(full_path, file, chunk_size, overwrite)
|
||||||
return success(result)
|
return success(result)
|
||||||
|
|
||||||
@@ -169,7 +185,10 @@ async def browse_fs(
|
|||||||
sort_by: str = Query("name", description="按字段排序: name, size, mtime"),
|
sort_by: str = Query("name", description="按字段排序: name, size, mtime"),
|
||||||
sort_order: str = Query("asc", description="排序顺序: asc, desc"),
|
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)
|
return success(data)
|
||||||
|
|
||||||
|
|
||||||
@@ -180,6 +199,7 @@ async def api_delete(
|
|||||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||||
full_path: str,
|
full_path: str,
|
||||||
):
|
):
|
||||||
|
await PermissionService.require_path_permission(current_user.id, full_path, PathAction.DELETE)
|
||||||
result = await VirtualFSService.delete(full_path)
|
result = await VirtualFSService.delete(full_path)
|
||||||
return success(result)
|
return success(result)
|
||||||
|
|
||||||
@@ -194,5 +214,8 @@ async def root_listing(
|
|||||||
sort_by: str = Query("name", description="按字段排序: name, size, mtime"),
|
sort_by: str = Query("name", description="按字段排序: name, size, mtime"),
|
||||||
sort_order: str = Query("asc", description="排序顺序: asc, desc"),
|
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)
|
return success(data)
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ from fastapi import HTTPException
|
|||||||
from api.response import page
|
from api.response import page
|
||||||
from domain.adapters import runtime_registry
|
from domain.adapters import runtime_registry
|
||||||
from domain.ai import FILE_COLLECTION_NAME, VECTOR_COLLECTION_NAME, VectorDBService
|
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 .thumbnail import is_image_filename, is_video_filename
|
||||||
from models import StorageAdapter
|
from models import StorageAdapter
|
||||||
|
|
||||||
@@ -245,3 +247,54 @@ class VirtualFSListingMixin(VirtualFSResolverMixin):
|
|||||||
info["vector_index"] = vector_index
|
info["vector_index"] = vector_index
|
||||||
|
|
||||||
return info
|
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
|
||||||
|
|||||||
@@ -18,4 +18,40 @@ class VirtualFSService(
|
|||||||
VirtualFSResolverMixin,
|
VirtualFSResolverMixin,
|
||||||
VirtualFSCommonMixin,
|
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
@@ -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",
|
||||||
|
]
|
||||||
|
|||||||
@@ -22,11 +22,95 @@ class UserAccount(Model):
|
|||||||
full_name = fields.CharField(max_length=100, null=True)
|
full_name = fields.CharField(max_length=100, null=True)
|
||||||
hashed_password = fields.CharField(max_length=128)
|
hashed_password = fields.CharField(max_length=128)
|
||||||
disabled = fields.BooleanField(default=False)
|
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:
|
class Meta:
|
||||||
table = "user"
|
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):
|
class Configuration(Model):
|
||||||
id = fields.IntField(pk=True)
|
id = fields.IntField(pk=True)
|
||||||
key = fields.CharField(max_length=100, unique=True)
|
key = fields.CharField(max_length=100, unique=True)
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ if str(PROJECT_ROOT) not in sys.path:
|
|||||||
|
|
||||||
from domain.config import VERSION
|
from domain.config import VERSION
|
||||||
from domain.auth import get_password_hash
|
from domain.auth import get_password_hash
|
||||||
|
from domain.permission.types import PERMISSION_DEFINITIONS
|
||||||
|
from domain.role.types import SystemRoles
|
||||||
|
|
||||||
|
|
||||||
def _project_root() -> Path:
|
def _project_root() -> Path:
|
||||||
@@ -127,6 +129,129 @@ def _cmd_reset_password(args: argparse.Namespace) -> int:
|
|||||||
return 0
|
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:
|
def _build_parser() -> argparse.ArgumentParser:
|
||||||
parser = argparse.ArgumentParser(prog="foxel")
|
parser = argparse.ArgumentParser(prog="foxel")
|
||||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
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.add_argument("--db", help="sqlite db 路径(默认 data/db/db.sqlite3)")
|
||||||
reset_password.set_defaults(func=_cmd_reset_password)
|
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
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export interface MeResponse {
|
|||||||
email?: string | null;
|
email?: string | null;
|
||||||
full_name?: string | null;
|
full_name?: string | null;
|
||||||
gravatar_url: string;
|
gravatar_url: string;
|
||||||
|
is_admin?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateMePayload {
|
export interface UpdateMePayload {
|
||||||
|
|||||||
@@ -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');
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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' });
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
@@ -750,5 +750,35 @@
|
|||||||
"Created": "Created",
|
"Created": "Created",
|
||||||
"Moved": "Moved",
|
"Moved": "Moved",
|
||||||
"Renamed": "Renamed",
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -750,5 +750,39 @@
|
|||||||
"Created": "已创建",
|
"Created": "已创建",
|
||||||
"Moved": "已移动",
|
"Moved": "已移动",
|
||||||
"Renamed": "已重命名",
|
"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": "存储适配器"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Layout, Menu, theme, Button, Modal, Tag, Tooltip, Descriptions, Alert, Divider, Spin } from 'antd';
|
import { Layout, Menu, theme, Button, Modal, Tag, Tooltip, Descriptions, Alert, Divider, Spin } from 'antd';
|
||||||
import { navGroups } from './nav.ts';
|
import { navGroups } from './nav.ts';
|
||||||
import type { NavItem, NavGroup } 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 { useSystemStatus } from '../contexts/SystemContext.tsx';
|
||||||
import {
|
import {
|
||||||
CheckCircleOutlined,
|
CheckCircleOutlined,
|
||||||
@@ -19,6 +19,7 @@ import { useTheme } from '../contexts/ThemeContext';
|
|||||||
import { useI18n } from '../i18n';
|
import { useI18n } from '../i18n';
|
||||||
import { useAppWindows } from '../contexts/AppWindowsContext';
|
import { useAppWindows } from '../contexts/AppWindowsContext';
|
||||||
import WeChatModal from '../components/WeChatModal';
|
import WeChatModal from '../components/WeChatModal';
|
||||||
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
const { Sider } = Layout;
|
const { Sider } = Layout;
|
||||||
|
|
||||||
export interface SideNavProps {
|
export interface SideNavProps {
|
||||||
@@ -33,12 +34,24 @@ const SideNav = memo(function SideNav({ collapsed, activeKey, onChange, onToggle
|
|||||||
const { token } = theme.useToken();
|
const { token } = theme.useToken();
|
||||||
const { resolvedMode } = useTheme();
|
const { resolvedMode } = useTheme();
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
const { user } = useAuth();
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
const [isVersionModalOpen, setIsVersionModalOpen] = useState(false);
|
const [isVersionModalOpen, setIsVersionModalOpen] = useState(false);
|
||||||
const [latestVersion, setLatestVersion] = useState<{
|
const [latestVersion, setLatestVersion] = useState<{
|
||||||
version: string;
|
version: string;
|
||||||
body: string;
|
body: string;
|
||||||
} | null>(null);
|
} | 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(() => {
|
useEffect(() => {
|
||||||
getLatestVersion().then(resp => {
|
getLatestVersion().then(resp => {
|
||||||
@@ -124,7 +137,7 @@ const SideNav = memo(function SideNav({ collapsed, activeKey, onChange, onToggle
|
|||||||
</div>
|
</div>
|
||||||
{/* 分组渲染 */}
|
{/* 分组渲染 */}
|
||||||
<div style={{ flex: 1, minHeight: 0, overflowY: 'auto', padding: '4px 4px 8px' }}>
|
<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 }}>
|
<div key={group.key} style={{ marginBottom: 12 }}>
|
||||||
{group.title && (
|
{group.title && (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -11,10 +11,11 @@ import {
|
|||||||
AppstoreOutlined,
|
AppstoreOutlined,
|
||||||
CodeOutlined,
|
CodeOutlined,
|
||||||
ClockCircleOutlined,
|
ClockCircleOutlined,
|
||||||
|
UserOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import type { ReactNode } from 'react';
|
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 interface NavGroup { key: string; title?: string; children: NavItem[]; }
|
||||||
|
|
||||||
export const navGroups: NavGroup[] = [
|
export const navGroups: NavGroup[] = [
|
||||||
@@ -42,6 +43,7 @@ export const navGroups: NavGroup[] = [
|
|||||||
key: 'system',
|
key: 'system',
|
||||||
title: 'System',
|
title: 'System',
|
||||||
children: [
|
children: [
|
||||||
|
{ key: 'users', icon: React.createElement(UserOutlined), label: 'User Management', adminOnly: true },
|
||||||
{ key: 'settings', icon: React.createElement(SettingOutlined), label: 'System Settings' },
|
{ key: 'settings', icon: React.createElement(SettingOutlined), label: 'System Settings' },
|
||||||
{ key: 'backup', icon: React.createElement(DatabaseOutlined), label: 'Backup & Restore' },
|
{ key: 'backup', icon: React.createElement(DatabaseOutlined), label: 'Backup & Restore' },
|
||||||
{ key: 'audit', icon: React.createElement(BugOutlined), label: 'Audit Logs' }
|
{ key: 'audit', icon: React.createElement(BugOutlined), label: 'Audit Logs' }
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -14,6 +14,7 @@ import SystemSettingsPage from '../pages/SystemSettingsPage/SystemSettingsPage.t
|
|||||||
import AuditLogsPage from '../pages/AuditLogsPage.tsx';
|
import AuditLogsPage from '../pages/AuditLogsPage.tsx';
|
||||||
import BackupPage from '../pages/SystemSettingsPage/BackupPage.tsx';
|
import BackupPage from '../pages/SystemSettingsPage/BackupPage.tsx';
|
||||||
import PluginsPage from '../pages/PluginsPage.tsx';
|
import PluginsPage from '../pages/PluginsPage.tsx';
|
||||||
|
import UsersPage from '../pages/AdminPage/UsersPage.tsx';
|
||||||
import { AppWindowsProvider, useAppWindows } from '../contexts/AppWindowsContext';
|
import { AppWindowsProvider, useAppWindows } from '../contexts/AppWindowsContext';
|
||||||
import { AppWindowsLayer } from '../apps/AppWindowsLayer';
|
import { AppWindowsLayer } from '../apps/AppWindowsLayer';
|
||||||
import AiAgentWidget from '../components/AiAgentWidget';
|
import AiAgentWidget from '../components/AiAgentWidget';
|
||||||
@@ -67,6 +68,7 @@ const ShellBody = memo(function ShellBody() {
|
|||||||
)}
|
)}
|
||||||
{navKey === 'audit' && <AuditLogsPage />}
|
{navKey === 'audit' && <AuditLogsPage />}
|
||||||
{navKey === 'backup' && <BackupPage />}
|
{navKey === 'backup' && <BackupPage />}
|
||||||
|
{navKey === 'users' && <UsersPage />}
|
||||||
</Flex>
|
</Flex>
|
||||||
</div>
|
</div>
|
||||||
</Layout.Content>
|
</Layout.Content>
|
||||||
|
|||||||
Reference in New Issue
Block a user